Skip to content

Commit

Permalink
[FEATURE] Added support to change the playback volume (#67)
Browse files Browse the repository at this point in the history
Added HTTP endpoint to change the playback volume.

Rules applied:
- if single device is connected, then change the device volume
- If multiple devices connected, then change only device that active
- If no device is connected to player, then error is returned

Written tests.
Added docs.
Small refactoring of classes.

Implementation of #66
  • Loading branch information
justJavaProgrammer authored Jul 29, 2024
1 parent 38907f8 commit 5a2458d
Show file tree
Hide file tree
Showing 30 changed files with 735 additions and 83 deletions.
1 change: 1 addition & 0 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ problem!
- [Get the current player state](docs/How-To-Fetch-Player-State.MD)
- [Start or resume playback](docs/How-To-Start-Playback.MD)
- [Pause playback](docs/How-To-Pause-Playback.MD)
- [Change playback volume](docs/How-To-Change-Volume.MD)

# Device authentication

Expand Down
26 changes: 26 additions & 0 deletions docs/How-To-Change-Volume.MD
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
### Change volume of the player

Set the volume for the user’s current playback device.

Note: the order of the commands are not guaranteed while using Player API.

#### Request

```http request
PUT /player/volume
Authorization: Bearer user_access_token
```

Query parameters:
- volume_percent - an integer from 0 to 100, where 0 means that device is muted and 100 max volume

#### Response

Status:
- 204 No Content - command has been received
- 400 Bad Request - invalid body, request or there is no active device. All responses with this status code have 'reason_code' in body, that can be used to determine type of the error
- 500 Server Error - should never happen, but if so - please, create a new GitHub issue that can be used to reproduce the issue.

See the [tests](../src/test/java/com/odeyalo/sonata/connect/controller/ChangePlayerVolumeEndpointTest.java) for further info


Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,21 @@ public Mono<ResponseEntity<?>> switchDevices(User user, @RequestBody DeviceSwitc
.thenReturn(default204Response());
}

@PutMapping(value = "/volume", produces = MediaType.APPLICATION_JSON_VALUE)
public Mono<ResponseEntity<?>> changePlayerVolume(@NotNull final Volume volume,
@NotNull final User user) {
return playerOperations.changeVolume(user, volume)
.map(it -> default204Response());
}

@DeleteMapping(value = "/device")
public Mono<ResponseEntity<?>> disconnectDevice(@RequestParam("device_id") String deviceId, User user) {
return playerOperations.getDeviceOperations()
.disconnectDevice(user, DisconnectDeviceArgs.withDeviceId(deviceId))
.thenReturn(default204Response());
}

@NotNull
private CurrentlyPlayingPlayerStateDto convertToCurrentlyPlayingStateDto(CurrentlyPlayingPlayerState state) {
return currentlyPlayingPlayerStateDtoConverter.convertTo(state);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public class PlayerStateDto {
RepeatState repeatState = RepeatState.OFF;
@JsonProperty("shuffle_state")
boolean shuffleState;
int volume;
@JsonProperty("currently_playing_type")
String currentlyPlayingType;
@JsonProperty("progress_ms")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public PlayerStateEntity create(@NotNull final CurrentPlayerState state) {
final PlayerStateEntity.PlayerStateEntityBuilder builder = PlayerStateEntity.builder()
.id(state.getId())
.playing(state.isPlaying())
.volume(state.getVolume().asInt())
.repeatState(state.getRepeatState())
.shuffleState(state.getShuffleState())
.user(UserEntity.builder().id(state.getUser().getId()).build())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,10 @@ public ResponseEntity<?> handleMalformedContextUriException(MalformedContextUriE
ExceptionMessage message = ExceptionMessage.of(ex.getMessage());
return badRequest().body(message);
}

@ExceptionHandler(InvalidVolumeException.class)
public ResponseEntity<?> handleInvalidVolumeException(final InvalidVolumeException ex) {
return ResponseEntity.badRequest()
.body(ReasonCodeAwareExceptionMessage.of("invalid_volume", ex.getMessage()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.odeyalo.sonata.connect.exception;

public final class InvalidVolumeException extends RuntimeException {

public static InvalidVolumeException withCustomMessage(final String message) {
return new InvalidVolumeException(message);
}

public static InvalidVolumeException withMessageAndCause(final String message, final Throwable cause) {
return new InvalidVolumeException(message, cause);
}

public InvalidVolumeException() {
super();
}

public InvalidVolumeException(final String message) {
super(message);
}

public InvalidVolumeException(final String message, final Throwable cause) {
super(message, cause);
}

public InvalidVolumeException(final Throwable cause) {
super(cause);
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,42 @@
package com.odeyalo.sonata.connect.exception;

import lombok.Getter;
import lombok.experimental.StandardException;
import org.jetbrains.annotations.NotNull;

/**
* Exception that should be thrown when there is no active device found for the given user
*/
@StandardException
@Getter
public class NoActiveDeviceException extends RuntimeException implements ReasonCodeAware {
final String reasonCode = "no_active_device";
public class NoActiveDeviceException extends PlayerCommandException {
static final String REASON_CODE = "no_active_device";
static final String DEFAULT_MESSAGE = "At least one connected device is required to execute this command";

@NotNull
public static NoActiveDeviceException defaultException() {
return withCustomMessage(DEFAULT_MESSAGE);
}

@NotNull
public static NoActiveDeviceException withCustomMessage(@NotNull final String message) {
return new NoActiveDeviceException(message);
}

@NotNull
public static NoActiveDeviceException withMessageAndCause(@NotNull final String message,
@NotNull final Throwable cause) {
return new NoActiveDeviceException(message, cause);
}

public NoActiveDeviceException() {
super(DEFAULT_MESSAGE, REASON_CODE);
}

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

private NoActiveDeviceException(@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,20 @@
package com.odeyalo.sonata.connect.exception;

public class PlayerCommandException extends RuntimeException implements ReasonCodeAware {
private final String reasonCode;

public PlayerCommandException(final String message, final String reasonCode) {
super(message);
this.reasonCode = reasonCode;
}

public PlayerCommandException(final String message, final String reasonCode, final Throwable cause) {
super(message, cause);
this.reasonCode = reasonCode;
}

@Override
public String getReasonCode() {
return reasonCode;
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
package com.odeyalo.sonata.connect.model;

import com.odeyalo.sonata.connect.service.player.TargetDeactivationDevice;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Value;
import lombok.With;
import com.odeyalo.sonata.connect.service.player.TargetDevice;
import lombok.*;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

Expand All @@ -15,7 +13,7 @@
*/
@Value
@AllArgsConstructor(staticName = "of")
@Builder
@Builder(toBuilder = true)
@With
public class CurrentPlayerState {
long id;
Expand All @@ -37,6 +35,9 @@ public class CurrentPlayerState {
PlayableItem playableItem;
@NotNull
User user;
@NotNull
@Builder.Default
Volume volume = Volume.muted();
long lastPauseTime = 0;
long playStartTime = 0;

Expand All @@ -48,10 +49,12 @@ public static CurrentPlayerState emptyFor(@NotNull final User user) {
.build();
}

@NotNull
public ShuffleMode getShuffleState() {
return shuffleState;
}

@Nullable
public PlayableItem getPlayingItem() {
return playableItem;
}
Expand All @@ -69,9 +72,36 @@ public CurrentPlayerState disconnectDevice(@NotNull final TargetDeactivationDevi
return withDevices(updatedDevices);
}

public boolean hasActiveDevice() {
return getDevices().hasActiveDevice();
}

public boolean hasDevice(@NotNull final TargetDevice searchTarget) {
return devices.hasDevice(searchTarget);
}

@NotNull
public CurrentPlayerState disconnectDevice(@NotNull final String deviceId) {
final TargetDeactivationDevice deactivationTarget = TargetDeactivationDevice.of(deviceId);
return disconnectDevice(deactivationTarget);
}

@NotNull
public CurrentPlayerState changeVolume(@NotNull final Volume volume) {

final Devices devices = this.devices.changeVolume(volume);

// For performance
return toBuilder()
.volume(volume)
.devices(devices)
.build();
}

@NotNull
public CurrentPlayerState transferPlayback(@NotNull final TargetDevice deviceToTransferPlayback) {
final var updatedDevices = devices.transferPlayback(deviceToTransferPlayback);

return withDevices(updatedDevices);
}
}
25 changes: 0 additions & 25 deletions src/main/java/com/odeyalo/sonata/connect/model/DeviceSpec.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.odeyalo.sonata.connect.model;

import org.jetbrains.annotations.NotNull;
import org.springframework.util.Assert;

/**
* Specification that describes a device that connected to Sonata-Connect
Expand Down Expand Up @@ -51,30 +50,6 @@ default boolean isIdle() {
return getStatus().isIdle();
}

/**
* Represent a volume for the device
* Volume MUST BE in range from 0 to 100
*
* @param value - an integer that represent a volume
*/
record Volume(int value) {
/**
* @throws IllegalStateException if a volume is in invalid range
*/
public Volume {
Assert.state(value >= 0, "Volume cannot be negative!");
Assert.state(value <= 100, "Volume must be in range 0 - 100!");
}

public static Volume from(int value) {
return new Volume(value);
}

public int asInt() {
return value;
}
}

/**
* Represent a current status of the device.
*/
Expand Down
17 changes: 17 additions & 0 deletions src/main/java/com/odeyalo/sonata/connect/model/Devices.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.odeyalo.sonata.connect.model;

import com.odeyalo.sonata.connect.exception.DeviceNotFoundException;
import com.odeyalo.sonata.connect.exception.NoActiveDeviceException;
import com.odeyalo.sonata.connect.service.player.TargetDeactivationDevice;
import com.odeyalo.sonata.connect.service.player.TargetDevice;
import lombok.*;
Expand Down Expand Up @@ -126,6 +127,22 @@ public Devices deactivateDevice(@NotNull final Device deviceToDeactivate) {
.addDevice(deactivatedDevice);
}

/**
* Change the volume for ACTIVE device
* @param volume - volume to set for active deivce
* @return - updated {@link Devices}
* @throws NoActiveDeviceException - if there is no active device present
*/
@NotNull
public Devices changeVolume(@NotNull final Volume volume) {
final Device device = getActiveDevice()
.orElseThrow(NoActiveDeviceException::defaultException);

return removeDevice(device.getId())
.addDevice(
device.withVolume(volume)
);
}

@NotNull
private Device findDeviceToActivate(@NotNull final TargetDevice searchTarget) {
Expand Down
39 changes: 39 additions & 0 deletions src/main/java/com/odeyalo/sonata/connect/model/Volume.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.odeyalo.sonata.connect.model;

import org.jetbrains.annotations.NotNull;
import org.springframework.util.Assert;

/**
* Represent a volume for the device
* Volume MUST BE in range from 0 to 100
*
* @param value - an integer that represent a volume
*/
public record Volume(int value) {
/**
* @throws IllegalStateException if a volume is in invalid range
*/
public Volume {
Assert.state(value >= 0, "Volume cannot be negative!");
Assert.state(value <= 100, "Volume must be in range 0 - 100!");
}

@NotNull
public static Volume from(final int value) {
return new Volume(value);
}

@NotNull
public static Volume fromInt(final int value) {
return from(value);
}

@NotNull
public static Volume muted() {
return new Volume(0);
}

public int asInt() {
return value;
}
}
Loading

0 comments on commit 5a2458d

Please sign in to comment.