diff --git a/pyatv/core/facade.py b/pyatv/core/facade.py index c17409434..4be00ccac 100644 --- a/pyatv/core/facade.py +++ b/pyatv/core/facade.py @@ -146,20 +146,22 @@ async def wakeup(self) -> None: return await self.relay("wakeup")() @shield.guard - async def skip_forward(self) -> None: + async def skip_forward(self, time_interval: float = 0.0) -> None: """Skip forward a time interval. - Skip interval is typically 15-30s, but is decided by the app. + If time_interval is not positive or not present, a default or app-chosen + time interval is used, which is typically 10, 15, 30, etc. seconds. """ - return await self.relay("skip_forward")() + return await self.relay("skip_forward")(time_interval) @shield.guard - async def skip_backward(self) -> None: - """Skip backwards a time interval. + async def skip_backward(self, time_interval: float = 0.0) -> None: + """Skip backward a time interval. - Skip interval is typically 15-30s, but is decided by the app. + If time_interval is not positive or not present, a default or app-chosen + time interval is used, which is typically 10, 15, 30, etc. seconds. """ - return await self.relay("skip_backward")() + return await self.relay("skip_backward")(time_interval) @shield.guard async def set_position(self, pos: int) -> None: diff --git a/pyatv/interface.py b/pyatv/interface.py index 8a8d56f06..336bf4a1e 100644 --- a/pyatv/interface.py +++ b/pyatv/interface.py @@ -407,18 +407,20 @@ async def wakeup(self) -> None: "SkipForward", "Skip forward a time interval.", ) - async def skip_forward(self) -> None: + async def skip_forward(self, time_interval: float = 0.0) -> None: """Skip forward a time interval. - Skip interval is typically 15-30s, but is decided by the app. + If time_interval is not positive or not present, a default or app-chosen + time interval is used, which is typically 10, 15, 30, etc. seconds. """ raise exceptions.NotSupportedError() @feature(37, "SkipBackward", "Skip backwards a time interval.") - async def skip_backward(self) -> None: - """Skip backwards a time interval. + async def skip_backward(self, time_interval: float = 0.0) -> None: + """Skip backward a time interval. - Skip interval is typically 15-30s, but is decided by the app. + If time_interval is not positive or not present, a default or app-chosen + time interval is used, which is typically 10, 15, 30, etc. seconds. """ raise exceptions.NotSupportedError() diff --git a/pyatv/protocols/companion/__init__.py b/pyatv/protocols/companion/__init__.py index dcd621ab6..7d48dda76 100644 --- a/pyatv/protocols/companion/__init__.py +++ b/pyatv/protocols/companion/__init__.py @@ -77,6 +77,9 @@ # that pyatv supports). PAIRING_WITH_PIN_SUPPORTED_MASK = 0x4000 +# As seen in the TV Remote App +_DEFAULT_SKIP_TIME = 10 + # pylint: disable=invalid-name @@ -339,6 +342,29 @@ async def previous(self) -> None: """Press key previous.""" await self.api.mediacontrol_command(MediaControlCommand.PreviousTrack) + async def skip_forward(self, time_interval: float = 0.0) -> None: + """Skip forward a time interval.""" + await self.api.mediacontrol_command( + MediaControlCommand.SkipBy, + { + "_skpS": float( + time_interval if time_interval > 0 else _DEFAULT_SKIP_TIME + ) + }, + ) + + async def skip_backward(self, time_interval: float = 0.0) -> None: + """Skip forward a time interval.""" + # float cast: opack fails with negative integers + await self.api.mediacontrol_command( + MediaControlCommand.SkipBy, + { + "_skpS": float( + -time_interval if time_interval > 0 else -_DEFAULT_SKIP_TIME + ) + }, + ) + async def channel_up(self) -> None: """Select next channel.""" await self._press_button(HidCommand.ChannelIncrement) diff --git a/pyatv/protocols/dmap/__init__.py b/pyatv/protocols/dmap/__init__.py index ca15e7025..662a975b3 100644 --- a/pyatv/protocols/dmap/__init__.py +++ b/pyatv/protocols/dmap/__init__.py @@ -353,23 +353,29 @@ async def volume_down(self) -> None: """Press key volume down.""" await self.apple_tv.ctrl_int_cmd("volumedown") - async def skip_forward(self) -> None: + async def skip_forward(self, time_interval: float = 0.0) -> None: """Skip forward a time interval. Skip interval is typically 15-30s, but is decided by the app. """ current_position = (await self.apple_tv.playstatus()).position if current_position: - await self.set_position(current_position + _DEFAULT_SKIP_TIME) + await self.set_position( + current_position + + (int(time_interval) if time_interval > 0 else _DEFAULT_SKIP_TIME) + ) - async def skip_backward(self) -> None: + async def skip_backward(self, time_interval: float = 0.0) -> None: """Skip backwards a time interval. Skip interval is typically 15-30s, but is decided by the app. """ current_position = (await self.apple_tv.playstatus()).position if current_position: - await self.set_position(current_position - _DEFAULT_SKIP_TIME) + await self.set_position( + current_position + - (int(time_interval) if time_interval > 0 else _DEFAULT_SKIP_TIME) + ) async def set_position(self, pos: int) -> None: """Seek in the current playing media.""" diff --git a/pyatv/protocols/mrp/__init__.py b/pyatv/protocols/mrp/__init__.py index 4323e9763..685d2dea9 100644 --- a/pyatv/protocols/mrp/__init__.py +++ b/pyatv/protocols/mrp/__init__.py @@ -71,6 +71,8 @@ _LOGGER = logging.getLogger(__name__) +_DEFAULT_SKIP_TIME = 15 + # Source: https://github.com/Daij-Djan/DDHidLib/blob/master/usb_hid_usages.txt _KEY_LOOKUP = { # name: [usage_page, usage] @@ -429,28 +431,31 @@ async def wakeup(self) -> None: """Wake up the device.""" await _send_hid_key(self.protocol, "wakeup", InputAction.SingleTap) - async def skip_forward(self) -> None: + async def skip_forward(self, time_interval: float = 0.0) -> None: """Skip forward a time interval. Skip interval is typically 15-30s, but is decided by the app. """ - await self._skip_command(CommandInfo_pb2.SkipForward) + await self._skip_command(CommandInfo_pb2.SkipForward, time_interval) - async def skip_backward(self) -> None: + async def skip_backward(self, time_interval: float = 0.0) -> None: """Skip backwards a time interval. Skip interval is typically 15-30s, but is decided by the app. """ - await self._skip_command(CommandInfo_pb2.SkipBackward) + await self._skip_command(CommandInfo_pb2.SkipBackward, time_interval) - async def _skip_command(self, command) -> None: + async def _skip_command(self, command, time_interval: float) -> None: info = self.psm.playing.command_info(command) + skip_interval: int + if time_interval > 0: + skip_interval = int(time_interval) # Pick the first preferred interval for simplicity - if info and info.preferredIntervals: + elif info and info.preferredIntervals: skip_interval = info.preferredIntervals[0] else: - skip_interval = 15 # Default value + skip_interval = _DEFAULT_SKIP_TIME # Default value await self._send_command(command, skipInterval=skip_interval) diff --git a/tests/fake_device/companion.py b/tests/fake_device/companion.py index f910e9f6d..fe6fda057 100644 --- a/tests/fake_device/companion.py +++ b/tests/fake_device/companion.py @@ -23,6 +23,7 @@ DEVICE_NAME = "Fake Companion ATV" INITIAL_VOLUME = 10.0 +INITIAL_DURATION = 10.0 VOLUME_STEP = 5.0 INITIAL_RTI_TEXT = "Fake Companion Keyboard Text" @@ -55,6 +56,7 @@ MediaControlCommand.NextTrack: "next", MediaControlCommand.PreviousTrack: "previous", MediaControlCommand.SetVolume: "set_volume", + MediaControlCommand.SkipBy: "skip", } @@ -97,6 +99,7 @@ def __init__(self): self.media_control_flags: int = MediaControlFlags.Volume self.interests: Set[str] = set() self.volume: float = INITIAL_VOLUME + self.duration: float = INITIAL_DURATION self.rti_clients: List[FakeCompanionService] = [] self._rti_focus_state: KeyboardFocusState = KeyboardFocusState.Focused self.rti_text: Optional[str] = INITIAL_RTI_TEXT @@ -454,8 +457,10 @@ def handle__mcc(self, message): if mcc == MediaControlCommand.SetVolume: # Make sure we send response before triggering event with volume update self.loop.call_soon(self.volume_changed(message["_c"]["_vol"] * 100.0)) - if mcc == MediaControlCommand.GetVolume: + elif mcc == MediaControlCommand.GetVolume: args["_vol"] = self.state.volume / 100.0 + elif mcc == MediaControlCommand.SkipBy: + self.state.duration = max(0, self.state.duration + message["_c"]["_skpS"]) elif mcc in MEDIA_CONTROL_MAP: _LOGGER.debug("Activated Media Control Command %s", mcc) self.state.latest_button = MEDIA_CONTROL_MAP[mcc] diff --git a/tests/protocols/companion/test_companion_functional.py b/tests/protocols/companion/test_companion_functional.py index 796c0c5e7..e4c55c63e 100644 --- a/tests/protocols/companion/test_companion_functional.py +++ b/tests/protocols/companion/test_companion_functional.py @@ -20,6 +20,7 @@ from pyatv.protocols.companion.api import SystemStatus from tests.fake_device.companion import ( + INITIAL_DURATION, INITIAL_RTI_TEXT, INITIAL_VOLUME, VOLUME_STEP, @@ -217,6 +218,29 @@ async def test_remote_control_buttons(companion_client, companion_state, button) assert companion_state.latest_button == button +async def test_remote_control_skip_forward_backward(companion_client, companion_state): + duration = companion_state.duration + await companion_client.remote_control.skip_forward() + await until(lambda: math.isclose(companion_state.duration, duration + 10)) + + duration = companion_state.duration + await companion_client.remote_control.skip_backward() + await until(lambda: math.isclose(companion_state.duration, duration - 10)) + + duration = companion_state.duration + await companion_client.remote_control.skip_forward(10.5) + await until(lambda: math.isclose(companion_state.duration, duration + 10.5)) + + duration = companion_state.duration + await companion_client.remote_control.skip_backward(7.3) + await until(lambda: math.isclose(companion_state.duration, duration - 7.3)) + + # "From beginning" + duration = companion_state.duration + await companion_client.remote_control.skip_backward(9999999.0) + await until(lambda: math.isclose(companion_state.duration, 0)) + + async def test_audio_set_volume(companion_client, companion_state, companion_usecase): await until(lambda: companion_client.audio.volume, INITIAL_VOLUME) diff --git a/tests/protocols/dmap/test_dmap_functional.py b/tests/protocols/dmap/test_dmap_functional.py index 821309536..49d82f9c6 100644 --- a/tests/protocols/dmap/test_dmap_functional.py +++ b/tests/protocols/dmap/test_dmap_functional.py @@ -270,6 +270,16 @@ async def test_skip_forward_backward(self): await self.atv.remote_control.skip_backward() metadata = await self.playing() self.assertEqual(metadata.position, prev_position - SKIP_TIME) + prev_position = metadata.position + + await self.atv.remote_control.skip_forward(13.0) + metadata = await self.playing() + self.assertEqual(metadata.position, prev_position + 13) + prev_position = metadata.position + + await self.atv.remote_control.skip_backward(11.0) + metadata = await self.playing() + self.assertEqual(metadata.position, prev_position - 11) async def test_reset_revision_if_push_updates_fail(self): """Test that revision is reset when an error occurs during push update. diff --git a/tests/protocols/mrp/test_mrp_functional.py b/tests/protocols/mrp/test_mrp_functional.py index 0c2b35932..9c36cec63 100644 --- a/tests/protocols/mrp/test_mrp_functional.py +++ b/tests/protocols/mrp/test_mrp_functional.py @@ -474,6 +474,13 @@ async def test_skip_forward_backward(self): self.usecase.change_metadata(title="dummy4") metadata = await self.playing(title="dummy4") self.assertEqual(metadata.position, prev_position - 8) + prev_position = metadata.position + + # Test specified skip time + await self.atv.remote_control.skip_forward(17) + self.usecase.change_metadata(title="dummy5") + metadata = await self.playing(title="dummy5") + self.assertEqual(metadata.position, prev_position + 17) async def test_button_play_pause(self): self.usecase.example_video(supported_commands=[CommandInfo_pb2.TogglePlayPause])