Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEATURE] Added support to change the playback volume #67

Merged
merged 8 commits into from
Jul 29, 2024
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
Loading