Skip to content

Commit

Permalink
Merge pull request #38 from CalPinSW/add-playback-controls
Browse files Browse the repository at this point in the history
Add playback controls
  • Loading branch information
CalPinSW authored Jul 25, 2024
2 parents bd33521 + 455bef8 commit 01f1b4e
Show file tree
Hide file tree
Showing 13 changed files with 143 additions and 41 deletions.
4 changes: 0 additions & 4 deletions .github/workflows/ci-pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,6 @@ on:
paths-ignore:
- "diagrams/*"
- "**/README.md"
pull_request:
paths-ignore:
- "diagrams/*"
- "**/README.md"

jobs:
build:
Expand Down
1 change: 1 addition & 0 deletions backend/src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def create_app():
SESSION_COOKIE_SAMESITE="None",
SESSION_COOKIE_SECURE="True",
)

cors = CORS(
app,
resources={
Expand Down
20 changes: 20 additions & 0 deletions backend/src/controllers/spotify.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from flask import Blueprint, make_response, request
from src.dataclasses.playback_info import PlaybackInfo
from src.dataclasses.playback_request import StartPlaybackRequest
from src.dataclasses.playlist import Playlist
from src.spotify import SpotifyClient

Expand Down Expand Up @@ -125,4 +126,23 @@ def add_album_to_playlist():
access_token=access_token, playlist_id=playlist_id, album_id=album_id
)

@spotify_controller.route("pause_playback", methods=["PUT"])
def pause_playback():
access_token = request.cookies.get("spotify_access_token")
return spotify.pause_playback(access_token)

@spotify_controller.route("start_playback", methods=["PUT"])
def start_playback():
access_token = request.cookies.get("spotify_access_token")
request_body = request.json
start_playback_request_body = (
StartPlaybackRequest.model_validate(request_body) if request_body else None
)
return spotify.start_playback(access_token, start_playback_request_body)

@spotify_controller.route("pause_or_start_playback", methods=["PUT"])
def pause_or_start_playback():
access_token = request.cookies.get("spotify_access_token")
return spotify.pause_or_start_playback(access_token)

return spotify_controller
1 change: 1 addition & 0 deletions backend/src/dataclasses/playback_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class PlaybackInfo(BaseModel):
track_duration: float
album_progress: float
album_duration: float
is_playing: bool

def get_formatted_artists(self) -> str:
return ", ".join(self.track_artists)
Expand Down
19 changes: 19 additions & 0 deletions backend/src/dataclasses/playback_request.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from pydantic import BaseModel
from typing import List, Optional


class StartPlaybackRequestPositionOffset(BaseModel):
position: int


class StartPlaybackRequestUriOffset(BaseModel):
uri: str


class StartPlaybackRequest(BaseModel):
context_uri: Optional[str] = None
uris: Optional[List[str]] = None
offset: Optional[
StartPlaybackRequestPositionOffset | StartPlaybackRequestUriOffset
] = None
position_ms: Optional[int] = None
78 changes: 58 additions & 20 deletions backend/src/spotify.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from flask import Response, make_response, redirect
from src.dataclasses.album import Album
from src.dataclasses.playback_info import PlaybackInfo, PlaylistProgression
from src.dataclasses.playback_request import StartPlaybackRequest
from src.dataclasses.playback_state import PlaybackState
from src.dataclasses.playlist import Playlist
from src.dataclasses.playlist_info import CurrentUserPlaylists, SimplifiedPlaylist
Expand All @@ -15,7 +16,7 @@
from src.dataclasses.user import User
from src.exceptions.Unauthorized import UnauthorizedException
from src.flask_config import Config
from src.utils.response_creator import ResponseCreator
from src.utils.response_creator import add_cookies_to_response

scope = [
"user-library-read",
Expand All @@ -28,6 +29,7 @@
"playlist-read-collaborative",
"playlist-modify-private",
"playlist-modify-public",
"user-modify-playback-state",
]


Expand Down Expand Up @@ -93,12 +95,9 @@ def refresh_access_token(self, refresh_token):
token_response = TokenResponse.model_validate(api_response)
access_token = token_response.access_token
user_info = self.get_current_user(access_token)
resp = (
ResponseCreator()
.with_cookies(
{"spotify_access_token": access_token, "user_id": user_info.id}
)
.create()
resp = add_cookies_to_response(
make_response(),
{"spotify_access_token": access_token, "user_id": user_info.id},
)
return resp

Expand All @@ -119,16 +118,13 @@ def request_access_token(self, code):
token_response = TokenResponse.model_validate(api_response)
access_token = token_response.access_token
user_info = self.get_current_user(access_token)
resp = (
ResponseCreator(redirect(f"{Config().FRONTEND_URL}/"))
.with_cookies(
{
"spotify_access_token": access_token,
"spotify_refresh_token": token_response.refresh_token,
"user_id": user_info.id,
}
)
.create()
resp = add_cookies_to_response(
make_response(redirect(f"{Config().FRONTEND_URL}/")),
{
"spotify_access_token": access_token,
"spotify_refresh_token": token_response.refresh_token,
"user_id": user_info.id,
},
)
return resp

Expand Down Expand Up @@ -293,7 +289,7 @@ def get_album(self, access_token, id):
album = Album.model_validate(api_album)
return album

def get_current_playback(self, access_token):
def get_current_playback(self, access_token) -> PlaybackState | None:
response = requests.get(
f"https://api.spotify.com/v1/me/player",
auth=BearerAuth(access_token),
Expand Down Expand Up @@ -345,6 +341,7 @@ def get_my_current_playback(self, access_token) -> PlaybackInfo | None:
"track_duration": api_playback.item.duration_ms,
"album_progress": album_progress,
"album_duration": album_duration,
"is_playing": api_playback.is_playing,
}
)

Expand All @@ -366,11 +363,13 @@ def get_playlist_progression(self, access_token, api_playback: PlaybackInfo):
}
)

def search_albums(self, access_token, search=None, offset=0) -> List[Album]:
def search_albums(
self, access_token, search=None, offset=0, limit=50
) -> List[Album]:
if search:
response = requests.get(
f"https://api.spotify.com/v1/albums/{id}",
data={"q": search, "type": "album", "limit": 50, "offset": offset},
data={"q": search, "type": "album", "limit": limit, "offset": offset},
headers={
"content-type": "application/json",
},
Expand Down Expand Up @@ -456,6 +455,45 @@ def is_album_in_playlist(self, access_token, playlist_id, album: Album) -> bool:
album_track_ids = [track.id for track in album.tracks.items]
return all(e in playlist_track_ids for e in album_track_ids)

def pause_playback(self, access_token) -> Response:
response = requests.put(
url="https://api.spotify.com/v1/me/player/pause",
headers={
"content-type": "application/json",
},
auth=BearerAuth(access_token),
)
return self.response_handler(
make_response("", response.status_code), jsonify=False
)

def start_playback(
self, access_token, start_playback_request_body: StartPlaybackRequest = None
) -> Response:
if not start_playback_request_body:
data = None
else:
data = start_playback_request_body.model_dump_json(exclude_none=True)

response = requests.put(
url="https://api.spotify.com/v1/me/player/play",
data=data,
headers={
"content-type": "application/json",
},
auth=BearerAuth(access_token),
)
return self.response_handler(
make_response("", response.status_code), jsonify=False
)

def pause_or_start_playback(self, access_token) -> Response:
is_playing = self.get_current_playback(access_token).is_playing
if is_playing:
return self.pause_playback(access_token)
else:
return self.start_playback(access_token)


def get_playlist_duration(playlist_info: List[PlaylistTrackObject]) -> int:
return sum(track.track.duration_ms for track in playlist_info)
Expand Down
2 changes: 2 additions & 0 deletions backend/src/tests/mock_builders/playback_info_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ def playback_info_builder(
track_duration=180000,
album_progress=1000000,
album_duration=18000000,
is_playing=True,
):
return PlaybackInfo.model_validate(
{
Expand All @@ -26,5 +27,6 @@ def playback_info_builder(
"track_duration": track_duration,
"album_progress": album_progress,
"album_duration": album_duration,
"is_playing": is_playing,
}
)
17 changes: 5 additions & 12 deletions backend/src/utils/response_creator.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
from flask import Response, make_response
from flask import Response


class ResponseCreator:
def __init__(self):
self.response = make_response()

def with_cookies(self, cookie_dict: dict):
for key, value in cookie_dict.items():
self.response.set_cookie(key, value, samesite="None", secure=True)
return self

def create(self) -> Response:
return self.response
def add_cookies_to_response(response: Response, cookie_dict: dict):
for key, value in cookie_dict.items():
response.set_cookie(key, value, samesite="None", secure=True)
return response
4 changes: 2 additions & 2 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ services:
build:
context: frontend
dockerfile: Dockerfile
target: production
target: development
ports:
- "8080:8080"
env_file:
Expand All @@ -21,7 +21,7 @@ services:
build:
context: backend
dockerfile: Dockerfile
target: production
target: development
ports:
- "5000:5000"
env_file:
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,25 @@ export const addAlbumToPlaylist = async (
albumId,
});
};

export const pausePlayback = async (): Promise<Response> => {
return jsonRequest(`spotify/pause_playback`, RequestMethod.PUT);
};

export const startPlayback = async (
context_uri?: string,
uris?: string[],
offset?: {position: number} | {uri: string},
position_ms?: number
): Promise<Response> => {
return jsonRequest(`spotify/start_playback`, RequestMethod.PUT, {
context_uri,
uris,
offset,
position_ms
});
};

export const pauseOrStartPlayback = async (): Promise<Response> => {
return jsonRequest(`spotify/pause_or_start_playback`, RequestMethod.PUT);
};
2 changes: 2 additions & 0 deletions frontend/src/api/jsonRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const backendUrl = process.env.BACKEND_URL;
export enum RequestMethod {
GET = "get",
POST = "post",
PUT = "put",
}

export const jsonRequest = async <I, O>(
Expand All @@ -16,6 +17,7 @@ export const jsonRequest = async <I, O>(
let fetchOptions: RequestInit = { credentials: "include" };
switch (method) {
case RequestMethod.POST:
case RequestMethod.PUT:
fetchOptions = {
...fetchOptions,
method: method,
Expand Down
1 change: 1 addition & 0 deletions frontend/src/interfaces/PlaybackInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface PlaybackInfo {
track_duration: number;
album_progress: number;
album_duration: number;
is_playing: boolean;
}

export interface PlaylistProgress {
Expand Down
13 changes: 10 additions & 3 deletions frontend/src/presentational/PlaybackFooter.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import React, { FC, useEffect, useState } from "react";
import { getPlaybackInfo, getPlaylistProgress } from "../api";
import { getPlaybackInfo, getPlaylistProgress, pauseOrStartPlayback, pausePlayback, startPlayback } from "../api";
import { PlaybackInfo, PlaylistProgress } from "../interfaces/PlaybackInfo";
import { ProgressCircle } from "../components/ProgressCircle";
import useWindowSize from "../hooks/useWindowSize";
Expand All @@ -15,11 +15,18 @@ const PlaybackFooter: FC = () => {
const { playbackInfo, playlistProgress } = usePlaybackContext();

if (!playbackInfo) return null;

const handlePausePlayClick = (): void => {
pauseOrStartPlayback()
}

return (
<div className="w-full h-fit bg-primary-300 px-4 py-2 text-sm sm:text-base">
<div className="flex space-x-4 sm:space-x-6">
<div className="flex flex-col space-y-2 w-1/5 max-w-32">
<img src={playbackInfo.artwork_url}></img>
<button className="opacity-80 w-full h-full" onClick={handlePausePlayClick}>
<img src={playbackInfo.artwork_url}></img>
</button>
<div>Playing:</div>
<div className="text-balance">
{playbackInfo.album_artists.join(", ")}
Expand All @@ -28,7 +35,7 @@ const PlaybackFooter: FC = () => {
<div className="flex flex-col w-4/5 text-sm space-y-2">
<div className="flex flex-row justify-between">
<div className="flex flex-row space-x-2">
<SongIcon className="my-auto w-8 h-8" />
<SongIcon className={`my-auto w-8 h-8 ${playbackInfo.is_playing && "animate-bounce"}`} />
<div className="my-auto text-balance">
{playbackInfo.track_title}
</div>
Expand Down

0 comments on commit 01f1b4e

Please sign in to comment.