Skip to content

Commit

Permalink
[FEATURE] Added endpoint to seek current playback position (#73)
Browse files Browse the repository at this point in the history
Added HTTP endpoint to seek playback position to specified.
  • Loading branch information
justJavaProgrammer authored Aug 21, 2024
1 parent 16b9dc2 commit cacea72
Show file tree
Hide file tree
Showing 22 changed files with 694 additions and 64 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import com.odeyalo.sonata.connect.service.player.EventPublisherDeviceOperationsDecorator;
import com.odeyalo.sonata.connect.service.player.EventPublisherPlayerOperationsDecorator;
import com.odeyalo.sonata.connect.service.player.sync.PlayerSynchronizationManager;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
Expand All @@ -16,9 +15,8 @@ public class PlayerOperationsConfiguration {
@Bean
@Primary
public EventPublisherPlayerOperationsDecorator eventPublisherPlayerOperationsDecorator(BasicPlayerOperations delegate,
PlayerSynchronizationManager synchronizationManager,
@Qualifier("eventPublisherDeviceOperations") DeviceOperations deviceOperations) {
return new EventPublisherPlayerOperationsDecorator(delegate, synchronizationManager, deviceOperations);
PlayerSynchronizationManager synchronizationManager) {
return new EventPublisherPlayerOperationsDecorator(delegate, synchronizationManager);
}

@Bean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.odeyalo.sonata.connect.model.Volume;
import com.odeyalo.sonata.connect.service.player.BasicPlayerOperations;
import com.odeyalo.sonata.connect.service.player.PlayCommandContext;
import com.odeyalo.sonata.connect.service.player.SeekPosition;
import com.odeyalo.sonata.connect.service.support.mapper.Converter;
import com.odeyalo.sonata.connect.service.support.mapper.dto.CurrentPlayerState2PlayerStateDtoConverter;
import com.odeyalo.sonata.connect.support.web.HttpStatus;
Expand Down Expand Up @@ -77,4 +78,12 @@ public Mono<ResponseEntity<?>> changePlayerVolume(@NotNull final Volume volume,
.subscribeOn(Schedulers.boundedElastic())
.map(it -> HttpStatus.default204Response());
}

@PutMapping(value = "/seek", produces = MediaType.APPLICATION_JSON_VALUE)
public Mono<ResponseEntity<?>> seekPlaybackPosition(@NotNull final User user,
@NotNull final SeekPosition seekPosition) {

return playerOperations.seekToPosition(user, seekPosition)
.thenReturn(HttpStatus.default204Response());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,36 @@ public ResponseEntity<?> handleMissingRequestParameterException(final MissingReq
return ResponseEntity.badRequest()
.body(ExceptionMessage.of(ex.getMessage()));
}

@ExceptionHandler(MissingPlayableItemException.class)
public ResponseEntity<?> handleMissingPlayableItemException(final MissingPlayableItemException ex) {
final var body = ReasonCodeAwareExceptionMessage.of(ex.getReasonCode(), "Player command error: no item is playing");

return ResponseEntity.badRequest()
.body(body);
}

@ExceptionHandler(InvalidSeekPositionException.class)
public ResponseEntity<?> handleInvalidSeekPositionException(final InvalidSeekPositionException ex) {
final var body = ReasonCodeAwareExceptionMessage.of(ex.getReasonCode(), "Player command error: position must be positive");

return ResponseEntity.badRequest()
.body(body);
}

@ExceptionHandler(SeekPositionExceedDurationException.class)
public ResponseEntity<?> handleSeekPositionExceedDurationException(final SeekPositionExceedDurationException ex) {
final var body = ReasonCodeAwareExceptionMessage.of(ex.getReasonCode(), "Player command error: position cannot be greater than track duration");

return ResponseEntity.badRequest()
.body(body);
}

@ExceptionHandler(UnsupportedSeekPositionPrecisionException.class)
public ResponseEntity<?> handleUnsupportedSeekPositionPrecisionException(final UnsupportedSeekPositionPrecisionException ex) {
final var body = ReasonCodeAwareExceptionMessage.of(ex.getReasonCode(), "Player command error: unsupported precision used. Supported case insensitive: MILLIS, SECONDS.");

return ResponseEntity.badRequest()
.body(body);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.odeyalo.sonata.connect.exception;

public final class InvalidSeekPositionException extends PlayerCommandException {
public static final String REASON_CODE = "invalid_position";
public static final String DESCRIPTION = "Invalid seek position supplied";

private InvalidSeekPositionException() {
super(DESCRIPTION, REASON_CODE);
}

private InvalidSeekPositionException(final String message) {
super(message, REASON_CODE);
}

private InvalidSeekPositionException(final String message, final Throwable cause) {
super(message, REASON_CODE, cause);
}

public static InvalidSeekPositionException defaultException() {
return new InvalidSeekPositionException();
}

public static InvalidSeekPositionException withMessage(String message) {
return new InvalidSeekPositionException(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.odeyalo.sonata.connect.exception;

import org.jetbrains.annotations.NotNull;

public final class MissingPlayableItemException extends PlayerCommandException{
public static final String REASON_CODE = "playable_item_required";

public MissingPlayableItemException(@NotNull final String message) {
super(message, REASON_CODE);
}

public MissingPlayableItemException(@NotNull final String message, @NotNull final Throwable cause) {
super(message, REASON_CODE, cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.odeyalo.sonata.connect.exception;

import org.jetbrains.annotations.NotNull;

public final class SeekPositionExceedDurationException extends PlayerCommandException {
public static final String REASON_CODE = "seek_position_exceed";

public SeekPositionExceedDurationException(@NotNull final String message) {
super(message, REASON_CODE);
}

public SeekPositionExceedDurationException(@NotNull final String message, @NotNull final Throwable cause) {
super(message, REASON_CODE, cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.odeyalo.sonata.connect.exception;

import lombok.EqualsAndHashCode;
import lombok.Value;
import org.jetbrains.annotations.NotNull;

@Value
@EqualsAndHashCode(callSuper = true)
public class UnsupportedSeekPositionPrecisionException extends PlayerCommandException {

private static final String REASON_CODE = "unsupported_precision";
private static final String DEFAULT_DESCRIPTION = "Seek position precision of received type is not supported";

private final String receivedPrecision;

public UnsupportedSeekPositionPrecisionException(final String receivedPrecision) {
super(DEFAULT_DESCRIPTION, REASON_CODE);
this.receivedPrecision = receivedPrecision;
}

public UnsupportedSeekPositionPrecisionException(@NotNull final String message,
@NotNull final String receivedPrecision) {
super(message, REASON_CODE);
this.receivedPrecision = receivedPrecision;
}

public UnsupportedSeekPositionPrecisionException(final String message, final String receivedPrecision, final Throwable cause) {
super(message, REASON_CODE, cause);
this.receivedPrecision = receivedPrecision;
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package com.odeyalo.sonata.connect.model;

import com.odeyalo.sonata.connect.exception.MissingPlayableItemException;
import com.odeyalo.sonata.connect.exception.SeekPositionExceedDurationException;
import com.odeyalo.sonata.connect.service.player.SeekPosition;
import com.odeyalo.sonata.connect.service.player.TargetDeactivationDevice;
import com.odeyalo.sonata.connect.service.player.TargetDevice;
import com.odeyalo.sonata.connect.support.time.Clock;
import com.odeyalo.sonata.connect.support.time.JavaClock;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Value;
import lombok.With;
import lombok.*;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

Expand All @@ -28,6 +28,7 @@ public class CurrentPlayerState {
RepeatState repeatState = RepeatState.OFF;
@NotNull
@Builder.Default
@With(value = AccessLevel.PRIVATE)
ShuffleMode shuffleState = ShuffleMode.OFF;
@Builder.Default
long progressMs = -1L;
Expand Down Expand Up @@ -182,6 +183,26 @@ public CurrentPlayerState pause() {
return this;
}

@NotNull
public CurrentPlayerState switchShuffleMode(@NotNull final ShuffleMode shuffleMode) {
return withShuffleState(shuffleMode);
}

@NotNull
public CurrentPlayerState seekTo(@NotNull final SeekPosition seekPosition) {

if ( playableItem == null ) {
throw new MissingPlayableItemException("Seek command requires playable active");
}

if ( seekPosition.exceeds(playableItem.getDuration()) ) {
throw new SeekPositionExceedDurationException("Position cannot be greater than item duration");
}

return withProgressMs(seekPosition.posMs())
.withPlayStartTime(clock.currentTimeMillis());
}

private long getCurrentProgressMs() {
return progressMs + computeElapsedTime();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ public interface PlayableItem {
@NotNull
ContextUri getContextUri();

/**
* @return duration of this item
*/
@NotNull
PlayableItemDuration getDuration();
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,6 @@ default Mono<CurrentPlayerState> createState(@NotNull User user) {
@NotNull
Mono<CurrentPlayerState> changeShuffle(@NotNull User user, @NotNull ShuffleMode shuffleMode);

@NotNull
DeviceOperations getDeviceOperations();

/**
* Start or resume the track.
* If track not specified then currently playing track starts to play
Expand Down Expand Up @@ -83,19 +80,29 @@ Mono<CurrentPlayerState> playOrResume(@NotNull User user,
@NotNull
Mono<CurrentPlayerState> changeVolume(@NotNull User user, @NotNull Volume volume);

/**
* Seek the player's position to specified
* @param user - owner of the player
* @param position - position to seek position to
* @return - updated player state
*/
@NotNull
Mono<CurrentPlayerState> seekToPosition(@NotNull User user,
@NotNull SeekPosition position);

/**
* Alias for #changeShuffle(User, true) method call
*/
@NotNull
default Mono<CurrentPlayerState> enableShuffle(User user) {
default Mono<CurrentPlayerState> enableShuffle(@NotNull final User user) {
return changeShuffle(user, ShuffleMode.ENABLED);
}

/**
* Alias for #changeShuffle(User, false) method call
*/
@NotNull
default Mono<CurrentPlayerState> disableShuffle(User user) {
default Mono<CurrentPlayerState> disableShuffle(@NotNull final User user) {
return changeShuffle(user, ShuffleMode.OFF);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.odeyalo.sonata.connect.service.player;

import com.odeyalo.sonata.connect.exception.NoActiveDeviceException;
import com.odeyalo.sonata.connect.model.*;
import com.odeyalo.sonata.connect.service.player.handler.PauseCommandHandlerDelegate;
import com.odeyalo.sonata.connect.service.player.handler.PlayCommandHandlerDelegate;
Expand All @@ -18,15 +17,13 @@
@Component
@RequiredArgsConstructor
public final class DefaultPlayerOperations implements BasicPlayerOperations {
private final DeviceOperations deviceOperations;
private final PlayCommandHandlerDelegate playCommandHandlerDelegate;
private final PauseCommandHandlerDelegate pauseCommandHandlerDelegate;
private final CurrentPlayerState2CurrentlyPlayingPlayerStateConverter playerStateConverter;
private final PlayerStateService playerStateService;

private final Logger logger = LoggerFactory.getLogger(DefaultPlayerOperations.class);


@Override
@NotNull
public Mono<CurrentPlayerState> currentState(@NotNull final User user) {
Expand All @@ -47,16 +44,10 @@ public Mono<CurrentlyPlayingPlayerState> currentlyPlayingState(@NotNull final Us
public Mono<CurrentPlayerState> changeShuffle(@NotNull final User user,
@NotNull final ShuffleMode shuffleMode) {
return playerStateService.loadPlayerState(user)
.map(state -> state.withShuffleState(shuffleMode))
.map(playerState -> playerState.switchShuffleMode(shuffleMode))
.flatMap(playerStateService::save);
}

@Override
@NotNull
public DeviceOperations getDeviceOperations() {
return deviceOperations;
}

@Override
@NotNull
public Mono<CurrentPlayerState> playOrResume(@NotNull final User user,
Expand All @@ -77,24 +68,17 @@ public Mono<CurrentPlayerState> changeVolume(@NotNull final User user,
@NotNull final Volume volume) {

return playerStateService.loadPlayerState(user)
.flatMap(playerState -> executeChangeVolumeCommand(playerState, volume))
.map(playerState -> playerState.changeVolume(volume))
.flatMap(playerStateService::save);
}

@Override
@NotNull
private static Mono<CurrentPlayerState> executeChangeVolumeCommand(@NotNull final CurrentPlayerState playerState,
@NotNull final Volume volume) {

// If we don't have active device, then we don't have connected devices at all
if ( playerState.hasActiveDevice() ) {
return Mono.just(
playerState.changeVolume(volume)
);
}

return Mono.defer(
() -> Mono.error(NoActiveDeviceException.defaultException())
);
public Mono<CurrentPlayerState> seekToPosition(@NotNull final User user,
@NotNull final SeekPosition position) {
return playerStateService.loadPlayerState(user)
.map(playerState -> playerState.seekTo(position))
.flatMap(playerStateService::save);
}

@NotNull
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,10 @@
public final class EventPublisherPlayerOperationsDecorator implements BasicPlayerOperations {
private final BasicPlayerOperations delegate;
private final PlayerSynchronizationManager synchronizationManager;
private final DeviceOperations deviceOperations;

public EventPublisherPlayerOperationsDecorator(BasicPlayerOperations delegate, PlayerSynchronizationManager synchronizationManager, DeviceOperations deviceOperations) {
public EventPublisherPlayerOperationsDecorator(BasicPlayerOperations delegate, PlayerSynchronizationManager synchronizationManager) {
this.delegate = delegate;
this.synchronizationManager = synchronizationManager;
this.deviceOperations = deviceOperations;
}

@Override
Expand All @@ -42,16 +40,10 @@ public Mono<CurrentPlayerState> changeShuffle(@NotNull User user, @NotNull Shuff
return delegate.changeShuffle(user, shuffleMode);
}

@Override
@NotNull
public DeviceOperations getDeviceOperations() {
return deviceOperations;
}

@Override
@NotNull
public Mono<CurrentPlayerState> playOrResume(@NotNull final User user,
@Nullable final PlayCommandContext context,
@NotNull final PlayCommandContext context,
@Nullable final TargetDevice targetDevice) {
return delegate.playOrResume(user, context, targetDevice)
.flatMap(it -> publishEvent(it, PLAYER_STATE_UPDATED, user));
Expand All @@ -72,6 +64,15 @@ public Mono<CurrentPlayerState> changeVolume(@NotNull final User user,
.flatMap(state -> publishEvent(state, PLAYER_STATE_UPDATED, user));
}

@Override
@NotNull
public Mono<CurrentPlayerState> seekToPosition(@NotNull final User user,
@NotNull final SeekPosition position) {
return delegate.seekToPosition(user, position)
.map(playerState -> playerState.seekTo(position))
.flatMap(state -> publishEvent(state, PLAYER_STATE_UPDATED, user));
}

@NotNull
private Mono<CurrentPlayerState> publishEvent(@NotNull CurrentPlayerState currentPlayerState,
@NotNull PlayerEvent.EventType eventType,
Expand Down
Loading

0 comments on commit cacea72

Please sign in to comment.