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

feat/room-music-player #98

Merged
merged 24 commits into from
Mar 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
af35a3b
refactor(ws): responses can now handle errors
MAXOUXAX Feb 7, 2024
fc15604
feat(ui): added loading state on button
MAXOUXAX Feb 7, 2024
132bfdf
feat(frontend): now displaying loaders whenever an action is being ex…
MAXOUXAX Feb 7, 2024
595c6f3
refactor(ws): started big refactor of websocket communication
MAXOUXAX Feb 10, 2024
fb8b9dd
refactor(ws): finished websocket refactoring by moving all local play…
MAXOUXAX Feb 12, 2024
5ded50c
feat(ui): improved loading state inside button by rendering all conte…
MAXOUXAX Feb 12, 2024
59e5e0c
fix(ui): fixed button not taking multiple lines if text overflows
MAXOUXAX Feb 12, 2024
acc5c09
style(room): improved active room layout
MAXOUXAX Feb 12, 2024
3259ef9
fix(backend): SoundCloud authentication not working, using web scrapi…
MAXOUXAX Feb 13, 2024
90ae88d
fix(ws): fixed payload format
MAXOUXAX Feb 13, 2024
d7e4dc9
chore: added updated_at date
MAXOUXAX Feb 13, 2024
3744a4a
refactor(frontend): moved progress bar inside PlayerControls
MAXOUXAX Feb 13, 2024
47cc63d
chore: added updated_at into playback state for Spotify
MAXOUXAX Feb 13, 2024
f60eb4d
fix(backend): removed useless return reply
MAXOUXAX Feb 13, 2024
63de905
feat(backend): added playbackState and previousPlaybackState for room
MAXOUXAX Feb 13, 2024
2688c5a
refactor(backend): moved playback controller loop inside RoomStorage
MAXOUXAX Feb 13, 2024
e8b33d0
feat(backend): when the queue is empty, immediately play the track wh…
MAXOUXAX Feb 14, 2024
a9ac92c
fix(backend): only start the track immediately if none is playing
MAXOUXAX Feb 14, 2024
bf6c702
feat: improve spotify errors handling, added music logic, queue handl…
MAXOUXAX Feb 21, 2024
4f91e1f
fix: instead of adding the music that just started, we add the next m…
MAXOUXAX Mar 4, 2024
f879674
fix: wrong url used when immediately playing track when the queue is …
MAXOUXAX Mar 19, 2024
9cc1772
chore(eslint): fixed eslint and prettier conflicts
MAXOUXAX Mar 21, 2024
62ba018
chore: removed console.log logs and used console.debug for important …
MAXOUXAX Mar 21, 2024
ecbdc3b
refactor: improved code readability and minor optimization
MAXOUXAX Mar 21, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
],
"typescript.surveys.enabled": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
// "source.organizeImports": "explicit"
"source.fixAll.eslint": "explicit",
"source.organizeImports": "explicit"
},
"files.eol": "\n"
}
84 changes: 83 additions & 1 deletion backend/src/RoomStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import Deezer from "./musicplatform/Deezer";
import MusicPlatform from "./musicplatform/MusicPlatform";
import SoundCloud from "./musicplatform/SoundCloud";
import Spotify from "./musicplatform/Spotify";
import { adminSupabase } from "./server";
import { QueueableRemote } from "./musicplatform/remotes/Remote";
import { adminSupabase, server } from "./server";
import Room from "./socketio/Room";
import { RoomWithForeignTable } from "./socketio/RoomDatabase";

Expand All @@ -12,6 +13,7 @@ const STREAMING_SERVICES = {
SoundCloud: "c99631a2-f06c-4076-80c2-13428944c3a8",
Deezer: "4f619f5d-4028-4724-87c4-f440df4659fe",
};
const MUSIC_ENDING_SOON_DELAY = 10000;

function getMusicPlatform(serviceId: string): MusicPlatform | null {
switch (serviceId) {
Expand All @@ -29,14 +31,94 @@ function getMusicPlatform(serviceId: string): MusicPlatform | null {
export default class RoomStorage {
private static singleton: RoomStorage;
private readonly data: Map<string, Room>;
private readonly startedTimer: Map<string, boolean>;

private constructor() {
this.data = new Map();
this.startedTimer = new Map();
}

startTimer() {
setInterval(async () => {
const allRooms = await RoomStorage.getRoomStorage().getRooms();
allRooms.forEach(async (room) => {
const remote = room.getRemote();
if (!remote) return;

const lastKnownPlaybackState = room.getPlaybackState();
// Avoid spamming REST APIs
if (
lastKnownPlaybackState !== null &&
!room.getStreamingService().isClientSide()
) {
if (Date.now() - lastKnownPlaybackState.updated_at < 5000) return;
}

// Fetching newest playback state and sending it to the room
const newPlaybackStateResponse = await remote.getPlaybackState();
server.io
.of(`/room/${room.uuid}`)
.emit("player:updatePlaybackState", newPlaybackStateResponse);

const newPlaybackState = newPlaybackStateResponse.data;
room.setPlaybackState(newPlaybackState);

if (!newPlaybackState) return console.debug("No music is playing");

const remainingTime =
newPlaybackState.duration - newPlaybackState.currentTime;
const hasTriggeredEndingSoonValue = this.startedTimer.get(room.uuid);

if (
remainingTime < MUSIC_ENDING_SOON_DELAY &&
!hasTriggeredEndingSoonValue &&
!(remote instanceof QueueableRemote)
) {
console.debug(
`The track of the room ${room.uuid} is ending soon, and it doesn't support queueing`
);
this.startedTimer.set(room.uuid, true);

setTimeout(() => {
if (!newPlaybackStateResponse.data?.isPlaying)
return console.debug("The track is not playing anymore");
this.startedTimer.set(room.uuid, false);

const nextTrack = room.shiftQueue();
if (!nextTrack) return console.debug("No more tracks in the queue");

remote.playTrack(nextTrack.url);
console.debug(`The player will now play ${nextTrack.url}`);
}, remainingTime);
}

const previousPlaybackState = room.getPreviousPlaybackState();

// If the track has changed, we add the next track to the queue of the player
if (newPlaybackState.url != previousPlaybackState?.url) {
console.debug(`The track of room ${room.uuid} has changed`);

if (remote instanceof QueueableRemote) {
let nextTrack = room.getQueue().at(0);

if (nextTrack?.url === newPlaybackState.url) {
room.shiftQueue();
nextTrack = room.getQueue().at(0);
}
if (!nextTrack) return console.debug("No more tracks in the queue");

remote.addToQueue(nextTrack.url);
console.debug(`Just added ${nextTrack.url} to the queue`);
}
}
});
}, 1000);
}

static getRoomStorage(): RoomStorage {
if (this.singleton === undefined) {
this.singleton = new RoomStorage();
this.singleton.startTimer();
}
return this.singleton;
}
Expand Down
2 changes: 1 addition & 1 deletion backend/src/musicplatform/Deezer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { JSONTrack } from "commons/backend-types";
import MusicPlatform from "./MusicPlatform";
import Remote from "./remotes/Remote";
import { Remote } from "./remotes/Remote";
import Room from "../socketio/Room";

export default class Deezer extends MusicPlatform {
Expand Down
2 changes: 1 addition & 1 deletion backend/src/musicplatform/MusicPlatform.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { JSONTrack } from "commons/backend-types";
import Remote from "./remotes/Remote";
import { Remote } from "./remotes/Remote";
import Room from "../socketio/Room";

export default abstract class MusicPlatform {
Expand Down
6 changes: 3 additions & 3 deletions backend/src/musicplatform/SoundCloud.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { JSONTrack } from "commons/backend-types";
import { Soundcloud, SoundcloudTrackV2 } from "soundcloud.ts";
import { JSONTrack } from "commons/Backend-types";
import Soundcloud, { SoundcloudTrackV2 } from "soundcloud.ts";
import MusicPlatform from "./MusicPlatform";
import Remote from "./remotes/Remote";
import { Remote } from "./remotes/Remote";
import SoundCloudRemote from "./remotes/SoundCloudRemote";
import Room from "../socketio/Room";

Expand Down
2 changes: 1 addition & 1 deletion backend/src/musicplatform/Spotify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { JSONTrack } from "commons/backend-types";
import { spotify } from "../server";
import MusicPlatform from "./MusicPlatform";
import Remote from "./remotes/Remote";
import { Remote } from "./remotes/Remote";
import SpotifyRemote from "./remotes/SpotifyRemote";
import Room from "../socketio/Room";
import { Track } from "@spotify/web-api-ts-sdk";
Expand Down
25 changes: 15 additions & 10 deletions backend/src/musicplatform/remotes/Remote.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { JSONTrack, PlayingJSONTrack } from "commons/backend-types";
import { Response } from "commons/socket.io-types";

export default abstract class Remote {
abstract getPlaybackState(): Promise<PlayingJSONTrack | null>;
abstract getQueue(): Promise<JSONTrack[]>;
abstract playTrack(trackId: string): Promise<{ error?: string }>;
abstract setVolume(volume: number): Promise<void>;
abstract seekTo(position: number): Promise<void>;
abstract play(): Promise<void>;
abstract pause(): Promise<void>;
abstract previous(): Promise<void>;
abstract next(): Promise<void>;
export abstract class Remote {
abstract getPlaybackState(): Promise<Response<PlayingJSONTrack | null>>;
abstract playTrack(trackId: string): Promise<Response<void>>;
abstract setVolume(volume: number): Promise<Response<void>>;
abstract seekTo(position: number): Promise<Response<void>>;
abstract play(): Promise<Response<void>>;
abstract pause(): Promise<Response<void>>;
abstract previous(): Promise<Response<void>>;
abstract next(): Promise<Response<void>>;
}

export abstract class QueueableRemote extends Remote {
abstract addToQueue(trackId: string): Promise<Response<void>>;
abstract getQueue(): Promise<Response<JSONTrack[]>>;
}
148 changes: 36 additions & 112 deletions backend/src/musicplatform/remotes/SoundCloudRemote.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { JSONTrack, PlayingJSONTrack } from "commons/backend-types";
import MusicPlatform from "../MusicPlatform";
import Remote from "./Remote";
import { Remote } from "./Remote";
import {
LocalPlayerServerToClientEvents,
Response,
} from "commons/socket.io-types";
import Room from "../../socketio/Room";

export default class SoundCloudRemote extends Remote {
Expand All @@ -24,137 +28,57 @@ export default class SoundCloudRemote extends Remote {
return this.room.getHostSocket();
}

async getPlaybackState(): Promise<PlayingJSONTrack | null> {
const hostSocket = await this.getHostSocket();
async emitAndListen<T>(
event: keyof LocalPlayerServerToClientEvents,
data?: unknown
): Promise<Response<T>> {
const hostSocket = this.getHostSocket();
if (!hostSocket) {
return null;
return { data: null, error: "Host socket not available" };
}

hostSocket.emit("player:getPlaybackState");
return new Promise((resolve) => {
hostSocket.on(
"player:getPlaybackState",
(state: PlayingJSONTrack | null) => {
resolve(state);
}
);
const listener = (response: unknown) => {
hostSocket.off(event, listener);
resolve(response as never);
};

hostSocket.on(event, listener);
hostSocket.emit(event, data as never);
});
}

async getQueue(): Promise<JSONTrack[]> {
return [];
async getPlaybackState(): Promise<Response<PlayingJSONTrack | null>> {
return this.emitAndListen<PlayingJSONTrack | null>(
"player:playbackStateRequest"
);
}

async playTrack(trackId: string): Promise<{ error?: string | undefined }> {
const hostSocket = await this.getHostSocket();
if (!hostSocket) {
return { error: "Host socket not available" };
}

hostSocket.emit("player:playTrack", trackId);

return new Promise((resolve) => {
hostSocket.on("player:playTrack", (error: string | undefined) => {
if (error) {
resolve({ error });
} else {
resolve({});
}
});
});
async playTrack(trackId: string): Promise<Response<void>> {
return this.emitAndListen<void>("player:playTrackRequest", trackId);
}

async setVolume(volume: number): Promise<void> {
const hostSocket = await this.getHostSocket();
if (!hostSocket) {
return;
}

hostSocket.emit("player:setVolume", {
volume,
});

return new Promise((resolve) => {
hostSocket.on("player:setVolume", () => {
resolve();
});
});
async setVolume(volume: number): Promise<Response<void>> {
return this.emitAndListen<void>("player:setVolumeRequest", volume);
}

async seekTo(position: number): Promise<void> {
const hostSocket = await this.getHostSocket();
if (!hostSocket) {
return;
}

hostSocket.emit("player:seekTo", {
position,
});

return new Promise((resolve) => {
hostSocket.on("player:seekTo", () => {
resolve();
});
});
async seekTo(position: number): Promise<Response<void>> {
return this.emitAndListen<void>("player:seekToRequest", position);
}

async play(): Promise<void> {
const hostSocket = await this.getHostSocket();
if (!hostSocket) {
return;
}

hostSocket.emit("player:play");

return new Promise((resolve) => {
hostSocket.on("player:play", () => {
resolve();
});
});
async play(): Promise<Response<void>> {
return this.emitAndListen<void>("player:playRequest");
}

async pause(): Promise<void> {
const hostSocket = await this.getHostSocket();
if (!hostSocket) {
return;
}

hostSocket.emit("player:pause");

return new Promise((resolve) => {
hostSocket.on("player:pause", () => {
resolve();
});
});
async pause(): Promise<Response<void>> {
return this.emitAndListen<void>("player:pauseRequest");
}

async previous(): Promise<void> {
const hostSocket = await this.getHostSocket();
if (!hostSocket) {
return;
}

hostSocket.emit("player:previous");

return new Promise((resolve) => {
hostSocket.on("player:previous", () => {
resolve();
});
});
async previous(): Promise<Response<void>> {
return this.emitAndListen<void>("player:previousRequest");
}

async next(): Promise<void> {
const hostSocket = await this.getHostSocket();
if (!hostSocket) {
return;
}

hostSocket.emit("player:next");

return new Promise((resolve) => {
hostSocket.on("player:next", () => {
resolve();
});
});
async next(): Promise<Response<void>> {
return this.emitAndListen<void>("player:skipRequest");
}
}
Loading
Loading