From af35a3b93a436cfbf24867c77c28ce45e682067f Mon Sep 17 00:00:00 2001 From: MAXOUXAX <24844231+MAXOUXAX@users.noreply.github.com> Date: Wed, 7 Feb 2024 17:05:19 +0100 Subject: [PATCH 01/24] refactor(ws): responses can now handle errors --- backend/src/musicplatform/remotes/Remote.ts | 19 +-- .../musicplatform/remotes/SoundCloudRemote.ts | 144 +++++------------- .../musicplatform/remotes/SpotifyRemote.ts | 80 +++++----- backend/src/server.ts | 5 +- backend/src/socketio/RoomIO.ts | 11 +- commons/socket.io-types.ts | 143 ++++++++++++++++- expo/components/player/LocalPlayer.tsx | 2 +- expo/components/player/RoomPlayer.tsx | 20 ++- .../{ => player}/SoundCloudPlayer.tsx | 4 +- 9 files changed, 253 insertions(+), 175 deletions(-) rename expo/components/{ => player}/SoundCloudPlayer.tsx (96%) diff --git a/backend/src/musicplatform/remotes/Remote.ts b/backend/src/musicplatform/remotes/Remote.ts index f72e801e..e795a49b 100644 --- a/backend/src/musicplatform/remotes/Remote.ts +++ b/backend/src/musicplatform/remotes/Remote.ts @@ -1,13 +1,14 @@ import { JSONTrack, PlayingJSONTrack } from "commons/backend-types"; +import { Response } from "commons/socket.io-types"; export default abstract class Remote { - abstract getPlaybackState(): Promise; - abstract getQueue(): Promise; - abstract playTrack(trackId: string): Promise<{ error?: string }>; - abstract setVolume(volume: number): Promise; - abstract seekTo(position: number): Promise; - abstract play(): Promise; - abstract pause(): Promise; - abstract previous(): Promise; - abstract next(): Promise; + abstract getPlaybackState(): Promise>; + abstract getQueue(): Promise>; + abstract playTrack(trackId: string): Promise>; + abstract setVolume(volume: number): Promise>; + abstract seekTo(position: number): Promise>; + abstract play(): Promise>; + abstract pause(): Promise>; + abstract previous(): Promise>; + abstract next(): Promise>; } diff --git a/backend/src/musicplatform/remotes/SoundCloudRemote.ts b/backend/src/musicplatform/remotes/SoundCloudRemote.ts index ab3a0bca..714e3cd9 100644 --- a/backend/src/musicplatform/remotes/SoundCloudRemote.ts +++ b/backend/src/musicplatform/remotes/SoundCloudRemote.ts @@ -1,6 +1,7 @@ import { JSONTrack, PlayingJSONTrack } from "commons/backend-types"; import MusicPlatform from "../MusicPlatform"; import Remote from "./Remote"; +import { Response } from "commons/socket.io-types"; import Room from "../../socketio/Room"; export default class SoundCloudRemote extends Remote { @@ -24,137 +25,62 @@ export default class SoundCloudRemote extends Remote { return this.room.getHostSocket(); } - async getPlaybackState(): Promise { - const hostSocket = await this.getHostSocket(); + async emitAndListen(event: string, data?: unknown): Promise> { + 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: T | Error) => { + hostSocket.off(event, listener); + if (response instanceof Error) { + resolve({ data: null, error: response.message }); + } else { + resolve({ data: response, error: null }); } - ); + }; + + hostSocket.on(event, listener); + hostSocket.emit(event, data); }); } - async getQueue(): Promise { - return []; + async getPlaybackState(): Promise> { + return this.emitAndListen( + "player:getPlaybackState" + ); } - 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 getQueue(): Promise> { + return this.emitAndListen("player:getQueue"); } - async setVolume(volume: number): Promise { - const hostSocket = await this.getHostSocket(); - if (!hostSocket) { - return; - } - - hostSocket.emit("player:setVolume", { - volume, - }); - - return new Promise((resolve) => { - hostSocket.on("player:setVolume", () => { - resolve(); - }); - }); + async playTrack(trackId: string): Promise> { + return this.emitAndListen("player:playTrack", trackId); } - async seekTo(position: number): Promise { - const hostSocket = await this.getHostSocket(); - if (!hostSocket) { - return; - } - - hostSocket.emit("player:seekTo", { - position, - }); - - return new Promise((resolve) => { - hostSocket.on("player:seekTo", () => { - resolve(); - }); - }); + async setVolume(volume: number): Promise> { + return this.emitAndListen("player:setVolume", { volume }); } - async play(): Promise { - const hostSocket = await this.getHostSocket(); - if (!hostSocket) { - return; - } - - hostSocket.emit("player:play"); - - return new Promise((resolve) => { - hostSocket.on("player:play", () => { - resolve(); - }); - }); + async seekTo(position: number): Promise> { + return this.emitAndListen("player:seekTo", { position }); } - async pause(): Promise { - const hostSocket = await this.getHostSocket(); - if (!hostSocket) { - return; - } - - hostSocket.emit("player:pause"); - - return new Promise((resolve) => { - hostSocket.on("player:pause", () => { - resolve(); - }); - }); + async play(): Promise> { + return this.emitAndListen("player:play"); } - async previous(): Promise { - const hostSocket = await this.getHostSocket(); - if (!hostSocket) { - return; - } - - hostSocket.emit("player:previous"); - - return new Promise((resolve) => { - hostSocket.on("player:previous", () => { - resolve(); - }); - }); + async pause(): Promise> { + return this.emitAndListen("player:pause"); } - async next(): Promise { - const hostSocket = await this.getHostSocket(); - if (!hostSocket) { - return; - } - - hostSocket.emit("player:next"); + async previous(): Promise> { + return this.emitAndListen("player:previous"); + } - return new Promise((resolve) => { - hostSocket.on("player:next", () => { - resolve(); - }); - }); + async next(): Promise> { + return this.emitAndListen("player:next"); } } diff --git a/backend/src/musicplatform/remotes/SpotifyRemote.ts b/backend/src/musicplatform/remotes/SpotifyRemote.ts index b5b44ad0..2c09bd1b 100644 --- a/backend/src/musicplatform/remotes/SpotifyRemote.ts +++ b/backend/src/musicplatform/remotes/SpotifyRemote.ts @@ -4,6 +4,7 @@ import { adminSupabase } from "../../server"; import MusicPlatform from "../MusicPlatform"; import Remote from "./Remote"; import Room from "../../socketio/Room"; +import { Response } from "commons/socket.io-types"; export default class SpotifyRemote extends Remote { spotifyClient: SpotifyApi; @@ -28,15 +29,18 @@ export default class SpotifyRemote extends Remote { .eq("id", room.uuid) .single(); - if (error) return null; - if (data.user_profile?.bound_services === undefined) return null; - if (data.user_profile?.bound_services.length === 0) return null; + if ( + error || + !data || + !data.user_profile || + !data.user_profile.bound_services + ) + return null; const { access_token, expires_in, refresh_token } = data.user_profile.bound_services[0]; - if (access_token === null || expires_in === null || refresh_token === null) - return null; + if (!access_token || !expires_in || !refresh_token) return null; const expiresIn = parseInt(expires_in); @@ -52,16 +56,13 @@ export default class SpotifyRemote extends Remote { return new SpotifyRemote(spotifyClient, musicPlatform); } - async getPlaybackState(): Promise { + async getPlaybackState(): Promise> { const spotifyPlaybackState = await this.spotifyClient.player.getPlaybackState(); - // If the item playing is an episode and not a music track, then we return null - // Since we don't need support for those, for now, and they don't contain necessary information - // such as the album name, artists name, etc. - if (spotifyPlaybackState === null) return null; + if (!spotifyPlaybackState || spotifyPlaybackState.item.type === "episode") + return { data: null, error: "No track is currently playing" }; - if (spotifyPlaybackState.item.type === "episode") return null; const playbackState = { ...spotifyPlaybackState, item: spotifyPlaybackState.item as Track, @@ -70,21 +71,24 @@ export default class SpotifyRemote extends Remote { const artistsName = extractArtistsName(playbackState.item.album.artists); return { - isPlaying: playbackState.is_playing, - albumName: playbackState.item.album.name, - artistsName: artistsName, - currentTime: playbackState.progress_ms, - duration: playbackState.item.duration_ms, - imgUrl: playbackState.item.album.images[0].url, - title: playbackState.item.name, - url: playbackState.item.external_urls.spotify, + data: { + isPlaying: playbackState.is_playing, + albumName: playbackState.item.album.name, + artistsName: artistsName, + currentTime: playbackState.progress_ms, + duration: playbackState.item.duration_ms, + imgUrl: playbackState.item.album.images[0].url, + title: playbackState.item.name, + url: playbackState.item.external_urls.spotify, + }, + error: null, }; } - async getQueue(): Promise { + async getQueue(): Promise> { const spotifyQueue = await this.spotifyClient.player.getUsersQueue(); - return spotifyQueue.queue + const queue = spotifyQueue.queue .filter((item) => item.type === "track") .map((item) => item as Track) .map((item) => { @@ -97,13 +101,15 @@ export default class SpotifyRemote extends Remote { url: item.external_urls.spotify, }; }); + + return { data: queue, error: null }; } - async playTrack(trackId: string): Promise<{ error?: string }> { + async playTrack(trackId: string): Promise> { const state = await this.spotifyClient.player.getPlaybackState(); if (!state || !state.device.id) { - return { error: "No device found" }; + return { data: null, error: "No device found" }; } await this.spotifyClient.player.startResumePlayback( @@ -112,51 +118,57 @@ export default class SpotifyRemote extends Remote { [`${trackId}`] ); - return {}; + return { data: undefined, error: null }; } - async setVolume(volume: number): Promise { + async setVolume(volume: number): Promise> { await this.spotifyClient.player.setPlaybackVolume(volume); + return { data: undefined, error: null }; } - async seekTo(position: number): Promise { + async seekTo(position: number): Promise> { await this.spotifyClient.player.seekToPosition(position); + return { data: undefined, error: null }; } - async play(): Promise { + async play(): Promise> { const state = await this.spotifyClient.player.getPlaybackState(); if (!state || !state.device.id) { - return; + return { data: null, error: "No device found" }; } await this.spotifyClient.player.startResumePlayback(state.device.id); + return { data: undefined, error: null }; } - async pause(): Promise { + async pause(): Promise> { const state = await this.spotifyClient.player.getPlaybackState(); if (!state || !state.device.id) { - return; + return { data: null, error: "No device found" }; } await this.spotifyClient.player.pausePlayback(state.device.id); + return { data: undefined, error: null }; } - async previous(): Promise { + async previous(): Promise> { const state = await this.spotifyClient.player.getPlaybackState(); if (!state || !state.device.id) { - return; + return { data: null, error: "No device found" }; } await this.spotifyClient.player.skipToPrevious(state.device.id); + return { data: undefined, error: null }; } - async next(): Promise { + async next(): Promise> { const state = await this.spotifyClient.player.getPlaybackState(); if (!state || !state.device.id) { - return; + return { data: null, error: "No device found" }; } await this.spotifyClient.player.skipToNext(state.device.id); + return { data: undefined, error: null }; } } diff --git a/backend/src/server.ts b/backend/src/server.ts index e7f6a819..422c0482 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -192,7 +192,10 @@ setInterval(async () => { return; if (!room.getStreamingService().isClientSide()) ignoreCount = 0; - const playbackState = (await room.getRemote()?.getPlaybackState()) ?? null; + const remote = room.getRemote(); + if (!remote) return; + + const playbackState = await remote.getPlaybackState(); server.io .of(`/room/${room.uuid}`) .emit("player:updatePlaybackState", playbackState); diff --git a/backend/src/socketio/RoomIO.ts b/backend/src/socketio/RoomIO.ts index f73f513d..d67fcfe9 100644 --- a/backend/src/socketio/RoomIO.ts +++ b/backend/src/socketio/RoomIO.ts @@ -64,14 +64,11 @@ export default function RoomIO( }); // We should check the origin of the request to prevent anyone that isn't the host from removing anything - socket.on("queue:remove", async (params: string) => { - const number = Number.parseInt(params); - if (Number.isSafeInteger(number)) { - if (number >= 0 && number < room.size()) { - await room.removeWithIndex(number); + socket.on("queue:remove", async (index: number) => { + if (Number.isSafeInteger(index)) { + if (index >= 0 && index < room.size()) { + await room.removeWithIndex(index); } - } else { - await room.removeWithLink(params); } sendQueueUpdated(); }); diff --git a/commons/socket.io-types.ts b/commons/socket.io-types.ts index 8f226be2..b53a016f 100644 --- a/commons/socket.io-types.ts +++ b/commons/socket.io-types.ts @@ -1,38 +1,169 @@ import { JSONTrack, PlayingJSONTrack, RoomJSON } from "./backend-types"; +export type Response = + | { data: T; error: null } + | { data: null; error: string }; + export interface ServerToClientEvents { + /** + * Sends the current queue to the client + * @param room The room object containing the queue + */ "queue:update": (room: RoomJSON | Error) => void; + + /** + * Sends the current playback state to the client + * @param playbackState The current playback state + */ "player:updatePlaybackState": ( - playbackState: PlayingJSONTrack | null + playbackState: Response ) => void; + + /** + * Sends a request to the client to end the room + */ + "room:end": () => void; + + /** + * Sends the current queue to the client + * This will be sent after the client requested the queue + */ + "player:getQueue": (queue: Response>) => void; + + /** + * IMPORTANT + * All following events will only be sent: + * - to the host + * - if the music player is local and therefore loaded on the host's device + */ + + /** + * Requests the current playback state to the client + */ "player:getPlaybackState": () => void; - "player:getQueue": (queue: JSONTrack[]) => void; + + /** + * Sends a request to the client to play a track with the given id + * @param trackId The id of the track to play (e.g. spotify:track:1234, https://soundcloud.com/12/34, etc.) + */ "player:playTrack": (trackId: string) => void; + + /** + * Sends a request to the client to set the volume to the given value + * @param volume The volume to set + */ "player:setVolume": (volume: number) => void; + + /** + * Sends a request to the client to seek to the given position + * @param position The position to seek to + */ "player:seekTo": (position: number) => void; + + /** + * Sends a request to the client to play the current track + */ "player:play": () => void; + + /** + * Sends a request to the client to pause the current track + */ "player:pause": () => void; + + /** + * Sends a request to the client to skip the current track + */ "player:skip": () => void; + + /** + * Sends a request to the client to play the previous track + */ "player:previous": () => void; - "room:end": () => void; } export interface ClientToServerEvents { + /** + * Sends a request to the server to add a track to the queue + * @param rawUrl The url of the track to add + */ "queue:add": (rawUrl: string) => void; - "queue:remove": (indexOrLink: string) => void; + + /** + * Sends a request to the server to remove a track from the queue + * @param index The index of the track to remove + */ + "queue:remove": (index: number) => void; + + /** + * Sends a request to the server to remove a track from the queue + * @param link The link of the track to remove + */ "queue:removeLink": (link: string) => void; - "queue:voteSkip": (index: number, userid: string) => void; // we found a better way to get the user id + + /** + * We found a better way to get the user id + * @param index index of the track to vote skip + * @param userid identifier of the user who wants to vote skip + */ + "queue:voteSkip": (index: number, userid: string) => void; + + /** + * Sends a request to the server to start the playback of a track + * @param trackId The id of the track to play (e.g. spotify:track:1234, https://soundcloud.com/12/34, etc.) + */ "player:playTrack": (trackId: string) => void; + + /** + * Sends a request to the server to set the volume to the given value + * @param volume + */ "player:setVolume": (volume: number) => void; + + /** + * Sends a request to the server to seek to the given position + * @param position + */ "player:seekTo": (position: number) => void; + + /** + * Sends a request to the server to play the current track + */ "player:play": () => void; + + /** + * Sends a request to the server to pause the current track + */ "player:pause": () => void; + + /** + * Sends a request to the server to skip the current track + */ "player:skip": () => void; + + /** + * Sends a request to the server to play the previous track + */ "player:previous": () => void; + + /** + * Sends a request to the server to get the current queue + */ "player:getQueue": () => void; + + /** + * Returns the current playback state to the server + * Will only be sent: + * - by the host + * - if the music player is local and therefore loaded on the host's device + * @param playbackState The current playback state + */ "player:updatePlaybackState": ( - playbackState: PlayingJSONTrack | null + playbackState: Response ) => void; + + /** + * Sends a request to the server to get the current playback state + */ "player:getPlaybackState": () => void; "utils:search": ( text: string, diff --git a/expo/components/player/LocalPlayer.tsx b/expo/components/player/LocalPlayer.tsx index cb741072..94975d48 100644 --- a/expo/components/player/LocalPlayer.tsx +++ b/expo/components/player/LocalPlayer.tsx @@ -6,9 +6,9 @@ import { useState, } from "react"; +import SoundCloudPlayer from "./SoundCloudPlayer"; import { AudioRemote } from "../../lib/audioRemote"; import { ActiveRoom } from "../../lib/useRoom"; -import SoundCloudPlayer from "../SoundCloudPlayer"; type LocalPlayerProps = { streamingService: ActiveRoom["streaming_services"]; diff --git a/expo/components/player/RoomPlayer.tsx b/expo/components/player/RoomPlayer.tsx index 34df09f7..c4f4ac1e 100644 --- a/expo/components/player/RoomPlayer.tsx +++ b/expo/components/player/RoomPlayer.tsx @@ -13,6 +13,7 @@ import PlayerControls from "./PlayerControls"; import buildAudioRemote, { AudioRemote } from "../../lib/audioRemote"; import { ActiveRoom } from "../../lib/useRoom"; import Button from "../Button"; +import Warning from "../Warning"; type RoomPlayerProps = { room: ActiveRoom; @@ -23,6 +24,7 @@ const RoomPlayer: React.FC = ({ room, socket }) => { const isHost = true; const [remote, setRemote] = useState(); const localPlayerRemote = useRef(null); + const [error, setError] = useState(); const [playbackState, setCurrentPlaybackState] = useState(null); @@ -39,19 +41,24 @@ const RoomPlayer: React.FC = ({ room, socket }) => { const currentPlaybackState = await localPlayerRemote.current.getPlaybackState(); - socket.emit("player:updatePlaybackState", currentPlaybackState); + socket.emit("player:updatePlaybackState", { + data: currentPlaybackState, + error: null, + }); }; useEffect(() => { if (!socket) return; setRemote(buildAudioRemote(socket)); - socket.on( - "player:updatePlaybackState", - (message: PlayingJSONTrack | null) => { - setCurrentPlaybackState(message); + socket.on("player:updatePlaybackState", (playbackState) => { + if (playbackState.error) { + setError(playbackState.error); + return; } - ); + + setCurrentPlaybackState(playbackState.data); + }); socket.on("player:getPlaybackState", () => { onStateRequest(socket); @@ -101,6 +108,7 @@ const RoomPlayer: React.FC = ({ room, socket }) => { return ( <> + {error && } {isHost && ( Date: Wed, 7 Feb 2024 18:07:06 +0100 Subject: [PATCH 02/24] feat(ui): added loading state on button --- expo/components/Button.tsx | 62 +++++++++++++++++++++++++++++--------- 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/expo/components/Button.tsx b/expo/components/Button.tsx index bd17e6ab..d1422826 100644 --- a/expo/components/Button.tsx +++ b/expo/components/Button.tsx @@ -1,10 +1,12 @@ import { MaterialIcons } from "@expo/vector-icons"; import { router } from "expo-router"; import { + ActivityIndicator, Pressable, PressableStateCallbackType, StyleProp, StyleSheet, + View, ViewStyle, } from "react-native"; @@ -24,6 +26,7 @@ export type ButtonProps = { children: React.ReactNode; onPress?: () => void; onLongPress?: () => void; + loading?: boolean; href?: string; disabled?: boolean; type?: "filled" | "outline"; @@ -54,6 +57,7 @@ const Button: React.FC = ({ block, color = "primary", style, + loading = false, }) => { const isSmall = size === "small"; const isFilled = type === "filled"; @@ -100,27 +104,55 @@ const Button: React.FC = ({ style={pressableStyle} onPress={handlePress} onLongPress={onLongPress} - disabled={disabled} + disabled={disabled || loading} accessibilityLabel={children as string} > - {prependIcon && ( - - )} - {icon && } - {!icon && ( - - {children} - + + )} - {appendIcon && ( - + {!loading && ( + <> + {prependIcon && ( + + )} + {icon && ( + + )} + {!icon && ( + + {children} + + )} + {appendIcon && ( + + )} + )} ); From 132bfdf8e89529a4d63de0565dbd247369f95292 Mon Sep 17 00:00:00 2001 From: MAXOUXAX <24844231+MAXOUXAX@users.noreply.github.com> Date: Wed, 7 Feb 2024 18:07:22 +0100 Subject: [PATCH 03/24] feat(frontend): now displaying loaders whenever an action is being executed --- expo/components/player/PlayerControls.tsx | 64 +++++++++++++++++++---- 1 file changed, 53 insertions(+), 11 deletions(-) diff --git a/expo/components/player/PlayerControls.tsx b/expo/components/player/PlayerControls.tsx index 4e9a9aa3..2dd70da8 100644 --- a/expo/components/player/PlayerControls.tsx +++ b/expo/components/player/PlayerControls.tsx @@ -1,9 +1,9 @@ import { PlayingJSONTrack } from "commons/backend-types"; -import { StyleSheet, View } from "react-native"; +import React, { useState } from "react"; +import { StyleSheet, View, Text } from "react-native"; import { AudioRemote } from "../../lib/audioRemote"; import Button from "../Button"; -import { Text } from "../Themed"; type PlayerControlsProps = { state: PlayingJSONTrack | null; @@ -11,27 +11,62 @@ type PlayerControlsProps = { }; const PlayerControls: React.FC = ({ state, remote }) => { - const handlePlayPause = () => { + const [loading, setLoading] = useState({ + previous: false, + playPause: false, + next: false, + }); + + const handlePlayPause = async () => { if (state === null) return; - return state.isPlaying ? remote.pause() : remote.play(); + setLoading((prevState) => ({ ...prevState, playPause: true })); + + try { + if (state.isPlaying) { + await remote.pause(); + } else { + await remote.play(); + } + } catch (error) { + console.error("Failed to play/pause:", error); + } finally { + setLoading((prevState) => ({ ...prevState, playPause: false })); + } }; - const handlePreviousTrack = () => { - return remote.previous(); + const handlePreviousTrack = async () => { + setLoading((prevState) => ({ ...prevState, previous: true })); + + try { + await remote.previous(); + } catch (error) { + console.error("Failed to go to previous track:", error); + } finally { + setLoading((prevState) => ({ ...prevState, previous: false })); + } }; - const handleNextTrack = () => { - return remote.next(); + const handleNextTrack = async () => { + setLoading((prevState) => ({ ...prevState, next: true })); + + try { + await remote.next(); + } catch (error) { + console.error("Failed to go to next track:", error); + } finally { + setLoading((prevState) => ({ ...prevState, next: false })); + } }; return ( - {state && ( + {state ? ( <> @@ -39,15 +74,22 @@ const PlayerControls: React.FC = ({ state, remote }) => { onPress={handlePlayPause} icon={state.isPlaying ? "pause" : "play-arrow"} type={state.isPlaying ? "outline" : "filled"} + loading={loading.playPause} > {state.isPlaying ? "Pause" : "Play"} - + ) : ( + Waiting for the host to play a song... )} - {!state && Waiting for the host to play a song...} ); }; From 595c6f30b2369be046d3e6cc5fa65b732973a08d Mon Sep 17 00:00:00 2001 From: MAXOUXAX <24844231+MAXOUXAX@users.noreply.github.com> Date: Sat, 10 Feb 2024 16:21:08 +0100 Subject: [PATCH 04/24] refactor(ws): started big refactor of websocket communication --- .../musicplatform/remotes/SoundCloudRemote.ts | 36 +-- backend/src/socketio/RoomIO.ts | 50 ++-- commons/socket.io-types.ts | 242 +++++++----------- expo/components/player/LocalPlayer.tsx | 12 +- expo/components/player/PlayerControls.tsx | 4 +- expo/components/player/RoomPlayer.tsx | 52 ++-- expo/components/player/SoundCloudPlayer.tsx | 67 +++-- expo/lib/audioRemote.ts | 95 +++---- expo/lib/soundcloud-widget-html.tsx | 7 +- 9 files changed, 256 insertions(+), 309 deletions(-) diff --git a/backend/src/musicplatform/remotes/SoundCloudRemote.ts b/backend/src/musicplatform/remotes/SoundCloudRemote.ts index 714e3cd9..f15aa39c 100644 --- a/backend/src/musicplatform/remotes/SoundCloudRemote.ts +++ b/backend/src/musicplatform/remotes/SoundCloudRemote.ts @@ -1,7 +1,10 @@ import { JSONTrack, PlayingJSONTrack } from "commons/backend-types"; import MusicPlatform from "../MusicPlatform"; import Remote from "./Remote"; -import { Response } from "commons/socket.io-types"; +import { + LocalPlayerServerToClientEvents, + Response, +} from "commons/socket.io-types"; import Room from "../../socketio/Room"; export default class SoundCloudRemote extends Remote { @@ -25,20 +28,19 @@ export default class SoundCloudRemote extends Remote { return this.room.getHostSocket(); } - async emitAndListen(event: string, data?: unknown): Promise> { + async emitAndListen( + event: keyof LocalPlayerServerToClientEvents, + data?: unknown + ): Promise> { const hostSocket = this.getHostSocket(); if (!hostSocket) { return { data: null, error: "Host socket not available" }; } return new Promise((resolve) => { - const listener = (response: T | Error) => { + const listener = (response: unknown) => { hostSocket.off(event, listener); - if (response instanceof Error) { - resolve({ data: null, error: response.message }); - } else { - resolve({ data: response, error: null }); - } + resolve(response as never); }; hostSocket.on(event, listener); @@ -48,39 +50,39 @@ export default class SoundCloudRemote extends Remote { async getPlaybackState(): Promise> { return this.emitAndListen( - "player:getPlaybackState" + "player:playbackStateRequest" ); } async getQueue(): Promise> { - return this.emitAndListen("player:getQueue"); + return this.emitAndListen("player:getQueueRequest"); } async playTrack(trackId: string): Promise> { - return this.emitAndListen("player:playTrack", trackId); + return this.emitAndListen("player:playTrackRequest", trackId); } async setVolume(volume: number): Promise> { - return this.emitAndListen("player:setVolume", { volume }); + return this.emitAndListen("player:setVolumeRequest", { volume }); } async seekTo(position: number): Promise> { - return this.emitAndListen("player:seekTo", { position }); + return this.emitAndListen("player:seekToRequest", { position }); } async play(): Promise> { - return this.emitAndListen("player:play"); + return this.emitAndListen("player:playRequest"); } async pause(): Promise> { - return this.emitAndListen("player:pause"); + return this.emitAndListen("player:pauseRequest"); } async previous(): Promise> { - return this.emitAndListen("player:previous"); + return this.emitAndListen("player:previousRequest"); } async next(): Promise> { - return this.emitAndListen("player:next"); + return this.emitAndListen("player:skipRequest"); } } diff --git a/backend/src/socketio/RoomIO.ts b/backend/src/socketio/RoomIO.ts index d67fcfe9..6d437814 100644 --- a/backend/src/socketio/RoomIO.ts +++ b/backend/src/socketio/RoomIO.ts @@ -99,70 +99,68 @@ export default function RoomIO( const remote = room.getRemote(); if (remote === null) return; - await remote.playTrack(trackId); - await updatePlaybackState(socket, remote); - }); - - socket.on("player:getQueue", async () => { - const remote = room.getRemote(); - if (remote === null) return; + const response = await remote.playTrack(trackId); + socket.emit("player:playTrack", response); - const queue = await remote.getQueue(); - socket.emit("player:getQueue", queue); + if (!response.error) await updatePlaybackState(socket, remote); }); socket.on("player:pause", async () => { const remote = room.getRemote(); if (remote === null) return; - await remote.pause(); - await updatePlaybackState(socket, remote); + const response = await remote.pause(); + socket.emit("player:pause", response); + + if (!response.error) await updatePlaybackState(socket, remote); }); socket.on("player:play", async () => { const remote = room.getRemote(); if (remote === null) return; - await remote.play(); - await updatePlaybackState(socket, remote); + const response = await remote.play(); + if (!response.error) await updatePlaybackState(socket, remote); + + socket.emit("player:play", response); }); socket.on("player:skip", async () => { const remote = room.getRemote(); if (remote === null) return; - await remote.next(); - await updatePlaybackState(socket, remote); + const response = await remote.next(); + socket.emit("player:skip", response); + + if (!response.error) await updatePlaybackState(socket, remote); }); socket.on("player:previous", async () => { const remote = room.getRemote(); if (remote === null) return; - await remote.previous(); - await updatePlaybackState(socket, remote); + const response = await remote.previous(); + socket.emit("player:previous", response); + + if (!response.error) await updatePlaybackState(socket, remote); }); socket.on("player:setVolume", async (volume) => { const remote = room.getRemote(); if (remote === null) return; - remote.setVolume(volume); + const response = await remote.setVolume(volume); + socket.emit("player:setVolume", response); }); socket.on("player:seekTo", async (position) => { const remote = room.getRemote(); if (remote === null) return; - await remote.seekTo(position); - await updatePlaybackState(socket, remote); - }); + const response = await remote.seekTo(position); + socket.emit("player:seekTo", response); - /** - * When we receive the new playback state, we send it to all clients - */ - socket.on("player:updatePlaybackState", async (playbackState) => { - socket.nsp.emit("player:updatePlaybackState", playbackState); + if (!response.error) await updatePlaybackState(socket, remote); }); socket.on( diff --git a/commons/socket.io-types.ts b/commons/socket.io-types.ts index b53a016f..76559094 100644 --- a/commons/socket.io-types.ts +++ b/commons/socket.io-types.ts @@ -4,169 +4,117 @@ export type Response = | { data: T; error: null } | { data: null; error: string }; -export interface ServerToClientEvents { - /** - * Sends the current queue to the client - * @param room The room object containing the queue - */ - "queue:update": (room: RoomJSON | Error) => void; +/** + * Interface for events that are sent from the local player to the server. + * These are requests that the local player makes to the server. + */ +export interface LocalPlayerServerToClientEvents { + /** Request the current playback state */ + "player:playbackStateRequest": () => void; + /** Request the current queue of the local player */ + "player:getQueueRequest": () => void; + /** Request to play a specific track. */ + "player:playTrackRequest": (trackId: string) => void; + /** Request to set the volume. */ + "player:setVolumeRequest": (volume: number) => void; + /** Request to seek to a specific position in the track. */ + "player:seekToRequest": (position: number) => void; + /** Request to play the current track. */ + "player:playRequest": () => void; + /** Request to pause the current track. */ + "player:pauseRequest": () => void; + /** Request to skip to the next track. */ + "player:skipRequest": () => void; + /** Request to go to the previous track. */ + "player:previousRequest": () => void; +} - /** - * Sends the current playback state to the client - * @param playbackState The current playback state - */ - "player:updatePlaybackState": ( +/** + * Interface for events that are sent from the client to the local player. + * These are responses to the requests that the local player made. + */ +export interface LocalPlayerClientToServerEvents { + /** Response to the playback state request. */ + "player:playbackStateRequest": ( playbackState: Response ) => void; + /** Response to the get queue request. */ + "player:getQueueRequest": (queue: Response) => void; + /** Response to the play track request. */ + "player:playTrackRequest": (trackId: string) => Response; + /** Response to the set volume request. */ + "player:setVolumeRequest": (volume: number) => Response; + /** Response to the seek to request. */ + "player:seekToRequest": (position: number) => Response; + /** Response to the play request. */ + "player:playRequest": () => Response; + /** Response to the pause request. */ + "player:pauseRequest": () => Response; + /** Response to the skip request. */ + "player:skipRequest": () => Response; + /** Response to the previous track request. */ + "player:previousRequest": () => Response; +} - /** - * Sends a request to the client to end the room - */ +/** + * Interface for events that are sent from the server to the client. + * These are responses to client requests or updates. + */ +export interface ServerToClientEvents + extends LocalPlayerServerToClientEvents, + PlayerServerToClientEvents { + /** Update the queue. */ + "queue:update": (room: RoomJSON | Error) => void; + /** End the room. */ "room:end": () => void; - - /** - * Sends the current queue to the client - * This will be sent after the client requested the queue - */ - "player:getQueue": (queue: Response>) => void; - - /** - * IMPORTANT - * All following events will only be sent: - * - to the host - * - if the music player is local and therefore loaded on the host's device - */ - - /** - * Requests the current playback state to the client - */ - "player:getPlaybackState": () => void; - - /** - * Sends a request to the client to play a track with the given id - * @param trackId The id of the track to play (e.g. spotify:track:1234, https://soundcloud.com/12/34, etc.) - */ - "player:playTrack": (trackId: string) => void; - - /** - * Sends a request to the client to set the volume to the given value - * @param volume The volume to set - */ - "player:setVolume": (volume: number) => void; - - /** - * Sends a request to the client to seek to the given position - * @param position The position to seek to - */ - "player:seekTo": (position: number) => void; - - /** - * Sends a request to the client to play the current track - */ - "player:play": () => void; - - /** - * Sends a request to the client to pause the current track - */ - "player:pause": () => void; - - /** - * Sends a request to the client to skip the current track - */ - "player:skip": () => void; - - /** - * Sends a request to the client to play the previous track - */ - "player:previous": () => void; } -export interface ClientToServerEvents { - /** - * Sends a request to the server to add a track to the queue - * @param rawUrl The url of the track to add - */ +/** + * Interface for events that are sent from the client to the server. + * These are requests that the client makes to the server. + */ +export interface ClientToServerEvents + extends LocalPlayerClientToServerEvents, + PlayerClientToServerEvents { + /** Add a track to the queue. */ "queue:add": (rawUrl: string) => void; - - /** - * Sends a request to the server to remove a track from the queue - * @param index The index of the track to remove - */ + /** Remove a track from the queue by its index. */ "queue:remove": (index: number) => void; - - /** - * Sends a request to the server to remove a track from the queue - * @param link The link of the track to remove - */ + /** Remove a track from the queue by its link. */ "queue:removeLink": (link: string) => void; + /** Search for tracks. */ + "utils:search": ( + text: string, + resultCallback: (args: JSONTrack[]) => void + ) => void; +} - /** - * We found a better way to get the user id - * @param index index of the track to vote skip - * @param userid identifier of the user who wants to vote skip - */ - "queue:voteSkip": (index: number, userid: string) => void; - - /** - * Sends a request to the server to start the playback of a track - * @param trackId The id of the track to play (e.g. spotify:track:1234, https://soundcloud.com/12/34, etc.) - */ +/** + * Interface for events that are sent from the client to the server that are related to the player. + * These are requests that the client makes to the server. + */ +interface PlayerClientToServerEvents { "player:playTrack": (trackId: string) => void; - - /** - * Sends a request to the server to set the volume to the given value - * @param volume - */ - "player:setVolume": (volume: number) => void; - - /** - * Sends a request to the server to seek to the given position - * @param position - */ - "player:seekTo": (position: number) => void; - - /** - * Sends a request to the server to play the current track - */ - "player:play": () => void; - - /** - * Sends a request to the server to pause the current track - */ "player:pause": () => void; - - /** - * Sends a request to the server to skip the current track - */ + "player:play": () => void; + "player:seekTo": (position: number) => void; + "player:setVolume": (volume: number) => void; "player:skip": () => void; - - /** - * Sends a request to the server to play the previous track - */ "player:previous": () => void; - - /** - * Sends a request to the server to get the current queue - */ - "player:getQueue": () => void; - - /** - * Returns the current playback state to the server - * Will only be sent: - * - by the host - * - if the music player is local and therefore loaded on the host's device - * @param playbackState The current playback state - */ +} +/** + * Interface for events that are sent from the server to the client that are related to the player. + * These are responses to client requests or updates. + */ +interface PlayerServerToClientEvents { + "player:playTrack": (response: Response) => void; + "player:pause": (response: Response) => void; + "player:play": (response: Response) => void; + "player:seekTo": (response: Response) => void; + "player:setVolume": (response: Response) => void; + "player:skip": (response: Response) => void; + "player:previous": (response: Response) => void; "player:updatePlaybackState": ( playbackState: Response ) => void; - - /** - * Sends a request to the server to get the current playback state - */ - "player:getPlaybackState": () => void; - "utils:search": ( - text: string, - resultCallback: (args: JSONTrack[]) => void - ) => void; } diff --git a/expo/components/player/LocalPlayer.tsx b/expo/components/player/LocalPlayer.tsx index 94975d48..59a961a6 100644 --- a/expo/components/player/LocalPlayer.tsx +++ b/expo/components/player/LocalPlayer.tsx @@ -7,19 +7,19 @@ import { } from "react"; import SoundCloudPlayer from "./SoundCloudPlayer"; -import { AudioRemote } from "../../lib/audioRemote"; +import { LocalPlayerRemote } from "../../lib/audioRemote"; import { ActiveRoom } from "../../lib/useRoom"; type LocalPlayerProps = { streamingService: ActiveRoom["streaming_services"]; }; -const LocalPlayer = forwardRef( +const LocalPlayer = forwardRef( ({ streamingService }, ref) => { - // Storing the ref of the SoundCloudPlayer component that returns a SoundCloudPlayerRemote - const soundCloudRef: React.RefObject = useRef(null); + // Storing the ref of the SoundCloudPlayer component that returns a LocalPlayerRemote + const soundCloudRef: React.RefObject = useRef(null); - const [remote, setRemote] = useState(null); + const [remote, setRemote] = useState(null); useEffect(() => { const remote = soundCloudRef.current; @@ -27,7 +27,7 @@ const LocalPlayer = forwardRef( }, [soundCloudRef]); useImperativeHandle(ref, () => { - return remote as AudioRemote; + return remote as LocalPlayerRemote; }); return ( diff --git a/expo/components/player/PlayerControls.tsx b/expo/components/player/PlayerControls.tsx index 2dd70da8..b995322e 100644 --- a/expo/components/player/PlayerControls.tsx +++ b/expo/components/player/PlayerControls.tsx @@ -2,12 +2,12 @@ import { PlayingJSONTrack } from "commons/backend-types"; import React, { useState } from "react"; import { StyleSheet, View, Text } from "react-native"; -import { AudioRemote } from "../../lib/audioRemote"; +import { PlayerRemote } from "../../lib/audioRemote"; import Button from "../Button"; type PlayerControlsProps = { state: PlayingJSONTrack | null; - remote: AudioRemote; + remote: PlayerRemote; }; const PlayerControls: React.FC = ({ state, remote }) => { diff --git a/expo/components/player/RoomPlayer.tsx b/expo/components/player/RoomPlayer.tsx index c4f4ac1e..2b934780 100644 --- a/expo/components/player/RoomPlayer.tsx +++ b/expo/components/player/RoomPlayer.tsx @@ -10,7 +10,10 @@ import { Socket } from "socket.io-client"; import LocalPlayer from "./LocalPlayer"; import Player from "./Player"; import PlayerControls from "./PlayerControls"; -import buildAudioRemote, { AudioRemote } from "../../lib/audioRemote"; +import buildAudioRemote, { + LocalPlayerRemote, + PlayerRemote, +} from "../../lib/audioRemote"; import { ActiveRoom } from "../../lib/useRoom"; import Button from "../Button"; import Warning from "../Warning"; @@ -22,31 +25,13 @@ type RoomPlayerProps = { const RoomPlayer: React.FC = ({ room, socket }) => { const isHost = true; - const [remote, setRemote] = useState(); - const localPlayerRemote = useRef(null); + const [remote, setRemote] = useState(); + const localPlayerRemote = useRef(null); const [error, setError] = useState(); const [playbackState, setCurrentPlaybackState] = useState(null); - /** - * When receiving a state request from the server, it means that the music platform in use - * uses a local player (eg. SoundCloud) and that the server needs to know the current playback - * state of the player. - */ - const onStateRequest = async (socket: RoomPlayerProps["socket"]) => { - if (!isHost) return; - if (!localPlayerRemote.current) return; - - const currentPlaybackState = - await localPlayerRemote.current.getPlaybackState(); - - socket.emit("player:updatePlaybackState", { - data: currentPlaybackState, - error: null, - }); - }; - useEffect(() => { if (!socket) return; setRemote(buildAudioRemote(socket)); @@ -60,34 +45,45 @@ const RoomPlayer: React.FC = ({ room, socket }) => { setCurrentPlaybackState(playbackState.data); }); - socket.on("player:getPlaybackState", () => { - onStateRequest(socket); + /** + * When receiving a state request from the server, it means that the music platform in use + * uses a local player (eg. SoundCloud) and that the server needs to know the current playback + * state of the player. + */ + socket.on("player:playbackStateRequest", async () => { + if (!isHost) return; + if (!localPlayerRemote.current) return; + + const currentPlaybackState = + await localPlayerRemote.current.getPlaybackState(); + + socket.emit("player:playbackStateRequest", currentPlaybackState); }); - socket.on("player:playTrack", (trackId: string) => { + socket.on("player:playTrackRequest", (trackId: string) => { if (localPlayerRemote.current) localPlayerRemote.current.playTrack(trackId); }); - socket.on("player:pause", async () => { + socket.on("player:pauseRequest", async () => { if (localPlayerRemote.current) { localPlayerRemote.current.pause(); } }); - socket.on("player:play", async () => { + socket.on("player:playRequest", async () => { if (localPlayerRemote.current) { localPlayerRemote.current.play(); } }); - socket.on("player:seekTo", async (position: number) => { + socket.on("player:seekToRequest", async (position: number) => { if (localPlayerRemote.current) { localPlayerRemote.current.seekTo(position); } }); - socket.on("player:setVolume", async (volume: number) => { + socket.on("player:setVolumeRequest", async (volume: number) => { if (localPlayerRemote.current) { localPlayerRemote.current.setVolume(volume); } diff --git a/expo/components/player/SoundCloudPlayer.tsx b/expo/components/player/SoundCloudPlayer.tsx index 46af341b..2a3b8cd3 100644 --- a/expo/components/player/SoundCloudPlayer.tsx +++ b/expo/components/player/SoundCloudPlayer.tsx @@ -1,4 +1,5 @@ import { PlayingJSONTrack } from "commons/backend-types"; +import { Response } from "commons/socket.io-types"; import React, { forwardRef, useEffect, @@ -9,11 +10,11 @@ import React, { import { Platform, StyleSheet } from "react-native"; import { WebView, WebViewMessageEvent } from "react-native-webview"; -import { AudioRemote } from "../../lib/audioRemote"; +import { LocalPlayerRemote } from "../../lib/audioRemote"; import getSoundCloudWidgetHtml from "../../lib/soundcloud-widget-html"; const SoundCloudPlayer = forwardRef< - AudioRemote, + LocalPlayerRemote, React.ComponentProps >((props, ref) => { const sendMessage = (message: object) => { @@ -48,13 +49,13 @@ const SoundCloudPlayer = forwardRef< seekTo, getPlaybackState, getQueue: async () => { - return []; + return { data: [], error: null }; }, next, previous, })); - const playTrack = async (url: string) => { + async function playTrack(url: string): Promise> { const command = { command: "playMusic", data: { @@ -67,24 +68,28 @@ const SoundCloudPlayer = forwardRef< }; sendMessage(command); - return {}; - }; + return { error: null, data: undefined }; + } - const play = async () => { + async function play(): Promise> { const command = { command: "play", }; sendMessage(command); - }; - const pause = async () => { + return { error: null, data: undefined }; + } + + async function pause(): Promise> { const command = { command: "pause", }; sendMessage(command); - }; - const setVolume = async (volume: number) => { + return { error: null, data: undefined }; + } + + async function setVolume(volume: number): Promise> { const command = { command: "setVolume", data: { @@ -92,9 +97,11 @@ const SoundCloudPlayer = forwardRef< }, }; sendMessage(command); - }; - const seekTo = async (position: number) => { + return { error: null, data: undefined }; + } + + async function seekTo(position: number): Promise> { const command = { command: "seekTo", data: { @@ -102,21 +109,25 @@ const SoundCloudPlayer = forwardRef< }, }; sendMessage(command); - }; - const [resolveCurrentState, setResolveCurrentState] = useState<() => void>(); + return { error: null, data: undefined }; + } + + const [resolveCurrentState, setResolveCurrentState] = + useState<(data: Response) => void>(); const handleWebViewMessage = (event: WebViewMessageEvent) => { const { data } = JSON.parse(event.nativeEvent.data); if (data.command === "currentMusic") { - if (resolveCurrentState) { - resolveCurrentState(); + const state = data.data as Response; + if (resolveCurrentStateRef.current) { + resolveCurrentStateRef.current(state); setResolveCurrentState(undefined); } } }; - const resolveCurrentStateRef = useRef<(data: any) => void>(); + const resolveCurrentStateRef = useRef<(data: Response) => void>(); useEffect(() => { resolveCurrentStateRef.current = resolveCurrentState; @@ -128,25 +139,33 @@ const SoundCloudPlayer = forwardRef< const { data } = event as MessageEvent; if (data.command === "currentMusic") { + const state = data.data as Response; if (resolveCurrentStateRef.current) { - resolveCurrentStateRef.current(data.data); + resolveCurrentStateRef.current(state); setResolveCurrentState(undefined); } } }; - const getPlaybackState = (): Promise => { - return new Promise((resolve) => { + async function getPlaybackState(): Promise< + Response + > { + return new Promise>((resolve) => { setResolveCurrentState(() => resolve); const command = { command: "fetchCurrent", }; sendMessage(command); }); - }; - const next = async () => {}; + } + + async function next(): Promise> { + return { error: null, data: undefined }; + } - const previous = async () => {}; + async function previous(): Promise> { + return { error: null, data: undefined }; + } const html = getSoundCloudWidgetHtml(); diff --git a/expo/lib/audioRemote.ts b/expo/lib/audioRemote.ts index 28830ca4..0e13a7eb 100644 --- a/expo/lib/audioRemote.ts +++ b/expo/lib/audioRemote.ts @@ -5,114 +5,93 @@ import { } from "commons/socket.io-types"; import { Socket } from "socket.io-client"; -export type AudioRemote = { - getPlaybackState(): Promise; - getQueue(): Promise; - playTrack(trackId: string): Promise<{ error?: string }>; - setVolume(volume: number): Promise; - seekTo(position: number): Promise; - play(): Promise; - pause(): Promise; - previous(): Promise; - next(): Promise; +type Response = { data: T; error: null } | { data: null; error: string }; + +export type LocalPlayerRemote = PlayerRemote & { + getPlaybackState(): Promise>; + getQueue(): Promise>; +}; + +export type PlayerRemote = { + playTrack(trackId: string): Promise>; + setVolume(volume: number): Promise>; + seekTo(position: number): Promise>; + play(): Promise>; + pause(): Promise>; + previous(): Promise>; + next(): Promise>; }; export default function buildAudioRemote( socket: Socket -): AudioRemote { +): PlayerRemote { return { - getPlaybackState(): Promise { - socket.emit("player:getPlaybackState"); - - return new Promise((resolve) => { - socket.on( - "player:updatePlaybackState", - (state: PlayingJSONTrack | null) => { - resolve(state); - } - ); - }); - }, - getQueue(): Promise { - socket.emit("player:getQueue"); - - return new Promise((resolve) => { - socket.on("player:getQueue", (queue: JSONTrack[]) => { - resolve(queue); - }); - }); - }, - - playTrack(trackId: string): Promise<{ error?: string | undefined }> { + playTrack(trackId: string): Promise> { socket.emit("player:playTrack", trackId); return new Promise((resolve) => { - socket.on("player:playTrack", (error: string | undefined) => { - if (error) { - resolve({ error }); - } else { - resolve({}); - } + socket.on("player:playTrack", (a) => { + resolve(a); }); }); }, - setVolume(volume: number): Promise { + setVolume(volume: number): Promise> { socket.emit("player:setVolume", volume); return new Promise((resolve) => { - socket.on("player:setVolume", () => { - resolve(); + socket.on("player:setVolume", (response) => { + resolve(response); }); }); }, - seekTo(position: number): Promise { + seekTo(position: number): Promise> { socket.emit("player:seekTo", position); return new Promise((resolve) => { - socket.on("player:seekTo", () => { - resolve(); + socket.on("player:seekTo", (response) => { + resolve(response); }); }); }, - play(): Promise { + play(): Promise> { socket.emit("player:play"); return new Promise((resolve) => { - socket.on("player:play", () => { - resolve(); + socket.on("player:play", (response) => { + resolve(response); }); }); }, - pause(): Promise { + pause(): Promise> { socket.emit("player:pause"); return new Promise((resolve) => { - socket.on("player:pause", () => { - resolve(); + socket.on("player:pause", (response) => { + resolve(response); }); }); }, - previous(): Promise { + previous(): Promise> { socket.emit("player:previous"); return new Promise((resolve) => { - socket.on("player:previous", () => { - resolve(); + socket.on("player:previous", (response) => { + resolve(response); }); }); }, - next(): Promise { + next(): Promise> { socket.emit("player:skip"); return new Promise((resolve) => { - socket.on("player:skip", () => { - resolve(); + socket.on("player:skip", (response) => { + resolve(response); }); }); }, diff --git a/expo/lib/soundcloud-widget-html.tsx b/expo/lib/soundcloud-widget-html.tsx index c0fb135f..5178f11d 100644 --- a/expo/lib/soundcloud-widget-html.tsx +++ b/expo/lib/soundcloud-widget-html.tsx @@ -69,10 +69,15 @@ export default function getSoundCloudWidgetHtml() { }); }); + const response = { + data: playingMusic, + error: null, + } + window.postMessage( { command: "currentMusic", - data: playingMusic, + data: response, }, "*" ); From fb8b9dd2ff42704b4bd57a787bb5620e5f1dabd3 Mon Sep 17 00:00:00 2001 From: MAXOUXAX <24844231+MAXOUXAX@users.noreply.github.com> Date: Mon, 12 Feb 2024 11:30:17 +0100 Subject: [PATCH 05/24] refactor(ws): finished websocket refactoring by moving all local player events to localplayer component --- commons/socket.io-types.ts | 18 ++-- expo/components/player/LocalPlayer.tsx | 117 ++++++++++++++++++++++--- expo/components/player/RoomPlayer.tsx | 54 +----------- 3 files changed, 119 insertions(+), 70 deletions(-) diff --git a/commons/socket.io-types.ts b/commons/socket.io-types.ts index 76559094..b65d6f8d 100644 --- a/commons/socket.io-types.ts +++ b/commons/socket.io-types.ts @@ -36,24 +36,24 @@ export interface LocalPlayerServerToClientEvents { export interface LocalPlayerClientToServerEvents { /** Response to the playback state request. */ "player:playbackStateRequest": ( - playbackState: Response + response: Response ) => void; /** Response to the get queue request. */ - "player:getQueueRequest": (queue: Response) => void; + "player:getQueueRequest": (response: Response) => void; /** Response to the play track request. */ - "player:playTrackRequest": (trackId: string) => Response; + "player:playTrackRequest": (response: Response) => void; /** Response to the set volume request. */ - "player:setVolumeRequest": (volume: number) => Response; + "player:setVolumeRequest": (response: Response) => void; /** Response to the seek to request. */ - "player:seekToRequest": (position: number) => Response; + "player:seekToRequest": (response: Response) => void; /** Response to the play request. */ - "player:playRequest": () => Response; + "player:playRequest": (response: Response) => void; /** Response to the pause request. */ - "player:pauseRequest": () => Response; + "player:pauseRequest": (response: Response) => void; /** Response to the skip request. */ - "player:skipRequest": () => Response; + "player:skipRequest": (response: Response) => void; /** Response to the previous track request. */ - "player:previousRequest": () => Response; + "player:previousRequest": (response: Response) => void; } /** diff --git a/expo/components/player/LocalPlayer.tsx b/expo/components/player/LocalPlayer.tsx index 59a961a6..660fae90 100644 --- a/expo/components/player/LocalPlayer.tsx +++ b/expo/components/player/LocalPlayer.tsx @@ -1,10 +1,9 @@ import { - forwardRef, - useEffect, - useImperativeHandle, - useRef, - useState, -} from "react"; + ClientToServerEvents, + ServerToClientEvents, +} from "commons/socket.io-types"; +import { forwardRef, useEffect, useRef, useState } from "react"; +import { Socket } from "socket.io-client"; import SoundCloudPlayer from "./SoundCloudPlayer"; import { LocalPlayerRemote } from "../../lib/audioRemote"; @@ -12,23 +11,109 @@ import { ActiveRoom } from "../../lib/useRoom"; type LocalPlayerProps = { streamingService: ActiveRoom["streaming_services"]; + socket: Socket; }; const LocalPlayer = forwardRef( - ({ streamingService }, ref) => { + ({ streamingService, socket }, ref) => { // Storing the ref of the SoundCloudPlayer component that returns a LocalPlayerRemote const soundCloudRef: React.RefObject = useRef(null); - const [remote, setRemote] = useState(null); + const [remote, setRemote] = useState(); useEffect(() => { const remote = soundCloudRef.current; + console.log("soundCloudRef loaded!", remote); + setRemote(remote); }, [soundCloudRef]); - useImperativeHandle(ref, () => { - return remote as LocalPlayerRemote; - }); + useEffect(() => { + const playbackStateRequest = async () => { + if (!remote) + return noLocalRemote("player:playbackStateRequest", socket); + + const currentPlaybackState = await remote.getPlaybackState(); + + socket.emit("player:playbackStateRequest", currentPlaybackState); + }; + + const playTrackRequest = async (trackId: string) => { + console.log( + "received request to play track with id", + trackId, + ", here's the current remote: ", + remote + ); + + if (!remote) return noLocalRemote("player:playTrackRequest", socket); + + const response = await remote.playTrack(trackId); + socket.emit("player:playTrackRequest", response); + }; + + const pauseRequest = async () => { + if (!remote) return noLocalRemote("player:pauseRequest", socket); + const response = await remote.pause(); + + socket.emit("player:pauseRequest", response); + }; + + const playRequest = async () => { + if (!remote) return noLocalRemote("player:playRequest", socket); + const response = await remote.play(); + + socket.emit("player:playRequest", response); + }; + + const seekToRequest = async (position: number) => { + if (!remote) return noLocalRemote("player:seekToRequest", socket); + + const response = await remote.seekTo(position); + socket.emit("player:seekToRequest", response); + }; + + const setVolumeRequest = async (volume: number) => { + if (!remote) return noLocalRemote("player:setVolumeRequest", socket); + + const response = await remote.setVolume(volume); + socket.emit("player:setVolumeRequest", response); + }; + + const skipRequest = async () => { + if (!remote) return noLocalRemote("player:skipRequest", socket); + + const response = await remote.next(); + socket.emit("player:skipRequest", response); + }; + + const prevRequest = async () => { + if (!remote) return noLocalRemote("player:previousRequest", socket); + + const response = await remote.previous(); + socket.emit("player:previousRequest", response); + }; + + socket.on("player:playbackStateRequest", playbackStateRequest); + socket.on("player:playTrackRequest", playTrackRequest); + socket.on("player:pauseRequest", pauseRequest); + socket.on("player:playRequest", playRequest); + socket.on("player:seekToRequest", seekToRequest); + socket.on("player:setVolumeRequest", setVolumeRequest); + socket.on("player:skipRequest", skipRequest); + socket.on("player:previousRequest", prevRequest); + + return () => { + socket.off("player:playbackStateRequest", playbackStateRequest); + socket.off("player:playTrackRequest", playTrackRequest); + socket.off("player:pauseRequest", pauseRequest); + socket.off("player:playRequest", playRequest); + socket.off("player:seekToRequest", seekToRequest); + socket.off("player:setVolumeRequest", setVolumeRequest); + socket.off("player:skipRequest", skipRequest); + socket.off("player:previousRequest", prevRequest); + }; + }, [socket, remote]); return ( <> @@ -42,3 +127,13 @@ const LocalPlayer = forwardRef( ); export default LocalPlayer; + +function noLocalRemote( + event: keyof ClientToServerEvents, + socket: Socket +): void | PromiseLike { + socket.emit(event, { + data: null, + error: "No local remote", + }); +} diff --git a/expo/components/player/RoomPlayer.tsx b/expo/components/player/RoomPlayer.tsx index 2b934780..eb96d446 100644 --- a/expo/components/player/RoomPlayer.tsx +++ b/expo/components/player/RoomPlayer.tsx @@ -10,10 +10,7 @@ import { Socket } from "socket.io-client"; import LocalPlayer from "./LocalPlayer"; import Player from "./Player"; import PlayerControls from "./PlayerControls"; -import buildAudioRemote, { - LocalPlayerRemote, - PlayerRemote, -} from "../../lib/audioRemote"; +import buildAudioRemote, { PlayerRemote } from "../../lib/audioRemote"; import { ActiveRoom } from "../../lib/useRoom"; import Button from "../Button"; import Warning from "../Warning"; @@ -26,7 +23,6 @@ type RoomPlayerProps = { const RoomPlayer: React.FC = ({ room, socket }) => { const isHost = true; const [remote, setRemote] = useState(); - const localPlayerRemote = useRef(null); const [error, setError] = useState(); const [playbackState, setCurrentPlaybackState] = @@ -37,6 +33,8 @@ const RoomPlayer: React.FC = ({ room, socket }) => { setRemote(buildAudioRemote(socket)); socket.on("player:updatePlaybackState", (playbackState) => { + setError(undefined); + if (playbackState.error) { setError(playbackState.error); return; @@ -44,50 +42,6 @@ const RoomPlayer: React.FC = ({ room, socket }) => { setCurrentPlaybackState(playbackState.data); }); - - /** - * When receiving a state request from the server, it means that the music platform in use - * uses a local player (eg. SoundCloud) and that the server needs to know the current playback - * state of the player. - */ - socket.on("player:playbackStateRequest", async () => { - if (!isHost) return; - if (!localPlayerRemote.current) return; - - const currentPlaybackState = - await localPlayerRemote.current.getPlaybackState(); - - socket.emit("player:playbackStateRequest", currentPlaybackState); - }); - - socket.on("player:playTrackRequest", (trackId: string) => { - if (localPlayerRemote.current) - localPlayerRemote.current.playTrack(trackId); - }); - - socket.on("player:pauseRequest", async () => { - if (localPlayerRemote.current) { - localPlayerRemote.current.pause(); - } - }); - - socket.on("player:playRequest", async () => { - if (localPlayerRemote.current) { - localPlayerRemote.current.play(); - } - }); - - socket.on("player:seekToRequest", async (position: number) => { - if (localPlayerRemote.current) { - localPlayerRemote.current.seekTo(position); - } - }); - - socket.on("player:setVolumeRequest", async (volume: number) => { - if (localPlayerRemote.current) { - localPlayerRemote.current.setVolume(volume); - } - }); }, [socket]); const playCoolSong = async () => { @@ -108,8 +62,8 @@ const RoomPlayer: React.FC = ({ room, socket }) => { {isHost && ( )} From 5ded50cef8e555130228501d71369e0d274d5641 Mon Sep 17 00:00:00 2001 From: MAXOUXAX <24844231+MAXOUXAX@users.noreply.github.com> Date: Mon, 12 Feb 2024 11:54:24 +0100 Subject: [PATCH 06/24] feat(ui): improved loading state inside button by rendering all content but setting its opacity to 0 --- expo/app/components/buttons.tsx | 1 + expo/components/Button.tsx | 67 +++++++++++++++++---------------- 2 files changed, 35 insertions(+), 33 deletions(-) diff --git a/expo/app/components/buttons.tsx b/expo/app/components/buttons.tsx index 40da4281..20464eae 100644 --- a/expo/app/components/buttons.tsx +++ b/expo/app/components/buttons.tsx @@ -11,6 +11,7 @@ const propOptions: Partial> = { prependIcon: ["home", null], appendIcon: ["home", null], color: ["primary", "success", "danger"], + loading: [false, true], }; function generateCombinations( diff --git a/expo/components/Button.tsx b/expo/components/Button.tsx index d1422826..2eb42a77 100644 --- a/expo/components/Button.tsx +++ b/expo/components/Button.tsx @@ -113,6 +113,7 @@ const Button: React.FC = ({ width: iconSize, height: iconSize, justifyContent: "center", + position: "absolute", }} > = ({ /> )} - {!loading && ( - <> - {prependIcon && ( - - )} - {icon && ( - - )} - {!icon && ( - - {children} - - )} - {appendIcon && ( - - )} - - )} + + {prependIcon && ( + + )} + {icon && ( + + )} + {!icon && ( + + {children} + + )} + {appendIcon && ( + + )} + ); }; From 59e5e0ce60bfea57ac9e6e257fb2603fca068cf3 Mon Sep 17 00:00:00 2001 From: MAXOUXAX <24844231+MAXOUXAX@users.noreply.github.com> Date: Mon, 12 Feb 2024 13:38:56 +0100 Subject: [PATCH 07/24] fix(ui): fixed button not taking multiple lines if text overflows --- expo/components/Button.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/expo/components/Button.tsx b/expo/components/Button.tsx index 2eb42a77..615bdeb0 100644 --- a/expo/components/Button.tsx +++ b/expo/components/Button.tsx @@ -124,13 +124,9 @@ const Button: React.FC = ({ )} {prependIcon && ( From acc5c09094bb6c5a7a02cef85fdb05c9b229f2ee Mon Sep 17 00:00:00 2001 From: MAXOUXAX <24844231+MAXOUXAX@users.noreply.github.com> Date: Mon, 12 Feb 2024 13:51:11 +0100 Subject: [PATCH 08/24] style(room): improved active room layout --- expo/app/(tabs)/rooms/[id]/index.tsx | 2 +- expo/components/ActiveRoomView.tsx | 7 ------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/expo/app/(tabs)/rooms/[id]/index.tsx b/expo/app/(tabs)/rooms/[id]/index.tsx index 97978552..eaae41c4 100644 --- a/expo/app/(tabs)/rooms/[id]/index.tsx +++ b/expo/app/(tabs)/rooms/[id]/index.tsx @@ -9,7 +9,7 @@ export default function RoomView() { const room = useRoom(id); return ( - + {room && room.is_active && } {room && !room.is_active && TODO} {!room && ( diff --git a/expo/components/ActiveRoomView.tsx b/expo/components/ActiveRoomView.tsx index 25c10e94..e5361b88 100644 --- a/expo/components/ActiveRoomView.tsx +++ b/expo/components/ActiveRoomView.tsx @@ -128,13 +128,6 @@ const ActiveRoomView: React.FC = ({ room }) => { } }; - const deleteRoom = async () => { - const response = await fetch(url + "/end", { credentials: "include" }); - if (!response.ok && process.env.NODE_ENV !== "production") { - Alert.alert(await response.text()); - } - }; - return ( <> From 3259ef961407282948c898aa0074e7efcb327855 Mon Sep 17 00:00:00 2001 From: MAXOUXAX <24844231+MAXOUXAX@users.noreply.github.com> Date: Tue, 13 Feb 2024 11:20:35 +0100 Subject: [PATCH 09/24] fix(backend): SoundCloud authentication not working, using web scraping temporarily --- backend/src/musicplatform/SoundCloud.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/musicplatform/SoundCloud.ts b/backend/src/musicplatform/SoundCloud.ts index 138fc50b..90eed9f4 100644 --- a/backend/src/musicplatform/SoundCloud.ts +++ b/backend/src/musicplatform/SoundCloud.ts @@ -1,5 +1,5 @@ -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 SoundCloudRemote from "./remotes/SoundCloudRemote"; From 90ae88d0dc3bd8a0631929b0346b9a236878a9e3 Mon Sep 17 00:00:00 2001 From: MAXOUXAX <24844231+MAXOUXAX@users.noreply.github.com> Date: Tue, 13 Feb 2024 16:28:56 +0100 Subject: [PATCH 10/24] fix(ws): fixed payload format --- backend/src/musicplatform/remotes/SoundCloudRemote.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/musicplatform/remotes/SoundCloudRemote.ts b/backend/src/musicplatform/remotes/SoundCloudRemote.ts index f15aa39c..f680547d 100644 --- a/backend/src/musicplatform/remotes/SoundCloudRemote.ts +++ b/backend/src/musicplatform/remotes/SoundCloudRemote.ts @@ -63,11 +63,11 @@ export default class SoundCloudRemote extends Remote { } async setVolume(volume: number): Promise> { - return this.emitAndListen("player:setVolumeRequest", { volume }); + return this.emitAndListen("player:setVolumeRequest", volume); } async seekTo(position: number): Promise> { - return this.emitAndListen("player:seekToRequest", { position }); + return this.emitAndListen("player:seekToRequest", position); } async play(): Promise> { From d7e4dc97064c4d00474461019b5d82fe73250152 Mon Sep 17 00:00:00 2001 From: MAXOUXAX <24844231+MAXOUXAX@users.noreply.github.com> Date: Tue, 13 Feb 2024 17:55:44 +0100 Subject: [PATCH 11/24] chore: added updated_at date --- commons/backend-types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/commons/backend-types.ts b/commons/backend-types.ts index 48a92d57..437b4ac9 100644 --- a/commons/backend-types.ts +++ b/commons/backend-types.ts @@ -11,6 +11,7 @@ export interface JSONTrack { export interface PlayingJSONTrack extends JSONTrack { currentTime: number; isPlaying: boolean; + updated_at: number; } export interface RoomJSON { From 3744a4a3ea1cda7739bb25f8106217fed54d5ab2 Mon Sep 17 00:00:00 2001 From: MAXOUXAX <24844231+MAXOUXAX@users.noreply.github.com> Date: Tue, 13 Feb 2024 17:56:09 +0100 Subject: [PATCH 12/24] refactor(frontend): moved progress bar inside PlayerControls --- expo/components/player/LocalPlayer.tsx | 9 -- expo/components/player/Player.tsx | 37 -------- expo/components/player/PlayerControls.tsx | 102 +++++++++++++++------- 3 files changed, 71 insertions(+), 77 deletions(-) diff --git a/expo/components/player/LocalPlayer.tsx b/expo/components/player/LocalPlayer.tsx index 660fae90..1f209d1b 100644 --- a/expo/components/player/LocalPlayer.tsx +++ b/expo/components/player/LocalPlayer.tsx @@ -23,8 +23,6 @@ const LocalPlayer = forwardRef( useEffect(() => { const remote = soundCloudRef.current; - console.log("soundCloudRef loaded!", remote); - setRemote(remote); }, [soundCloudRef]); @@ -39,13 +37,6 @@ const LocalPlayer = forwardRef( }; const playTrackRequest = async (trackId: string) => { - console.log( - "received request to play track with id", - trackId, - ", here's the current remote: ", - remote - ); - if (!remote) return noLocalRemote("player:playTrackRequest", socket); const response = await remote.playTrack(trackId); diff --git a/expo/components/player/Player.tsx b/expo/components/player/Player.tsx index dc13ae31..1933d4ec 100644 --- a/expo/components/player/Player.tsx +++ b/expo/components/player/Player.tsx @@ -12,12 +12,6 @@ const blurhash = "|rF?hV%2WCj[ayj[a|j[az_NaeWBj@ayfRayfQfQM{M|azj[azf6fQfQfQIpWXofj[ayj[j[fQayWCoeoeaya}j[ayfQa{oLj?j[WVj[ayayj[fQoff7azayj[ayj[j[ayofayayayj[fQj[ayayj[ayfjj[j[ayjuayj["; const Player: React.FC = ({ state, children }) => { - const formatDuration = (durationMs: number) => { - const minutes = Math.floor(durationMs / 60000); - const seconds = Math.floor((durationMs % 60000) / 1000); - return `${minutes}:${seconds.toString().padStart(2, "0")}`; - }; - return ( {state && ( @@ -33,20 +27,6 @@ const Player: React.FC = ({ state, children }) => { {state.artistsName} - - {formatDuration(state.currentTime)} - - - - {formatDuration(state.duration)} - {children} @@ -75,23 +55,6 @@ const styles = StyleSheet.create({ flexDirection: "row", alignItems: "center", }, - progressContainer: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - }, - progressBar: { - flex: 1, - height: 4, - backgroundColor: "#D1D5DB", - borderRadius: 9999, - }, - progress: { - height: "100%", - backgroundColor: "#000000", - borderRadius: 9999, - transition: "all 1s linear", - }, }); export default Player; diff --git a/expo/components/player/PlayerControls.tsx b/expo/components/player/PlayerControls.tsx index b995322e..4a07002f 100644 --- a/expo/components/player/PlayerControls.tsx +++ b/expo/components/player/PlayerControls.tsx @@ -16,6 +16,11 @@ const PlayerControls: React.FC = ({ state, remote }) => { playPause: false, next: false, }); + const formatDuration = (durationMs: number) => { + const minutes = Math.floor(durationMs / 60000); + const seconds = Math.floor((durationMs % 60000) / 1000); + return `${minutes}:${seconds.toString().padStart(2, "0")}`; + }; const handlePlayPause = async () => { if (state === null) return; @@ -58,38 +63,56 @@ const PlayerControls: React.FC = ({ state, remote }) => { } }; + if (!state) return <>; + + function skipTo90() { + if (!state) return; + remote.seekTo(0.9 * state.duration); + } + return ( - - {state ? ( - <> - - - - - ) : ( - Waiting for the host to play a song... - )} + + + + {formatDuration(state.currentTime)} + + + + {formatDuration(state.duration)} + + + + + + ); }; @@ -101,6 +124,23 @@ const styles = StyleSheet.create({ justifyContent: "space-between", gap: 3, }, + progressContainer: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + progressBar: { + flex: 1, + height: 4, + backgroundColor: "#D1D5DB", + borderRadius: 9999, + }, + progress: { + height: "100%", + backgroundColor: "#000000", + borderRadius: 9999, + transition: "all 1s linear", + }, }); export default PlayerControls; From 47cc63d173aee05850de2c033b39be5e33d0d81e Mon Sep 17 00:00:00 2001 From: MAXOUXAX <24844231+MAXOUXAX@users.noreply.github.com> Date: Tue, 13 Feb 2024 17:57:23 +0100 Subject: [PATCH 13/24] chore: added updated_at into playback state for Spotify --- backend/src/musicplatform/remotes/SpotifyRemote.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/src/musicplatform/remotes/SpotifyRemote.ts b/backend/src/musicplatform/remotes/SpotifyRemote.ts index 2c09bd1b..fefa95ca 100644 --- a/backend/src/musicplatform/remotes/SpotifyRemote.ts +++ b/backend/src/musicplatform/remotes/SpotifyRemote.ts @@ -80,6 +80,7 @@ export default class SpotifyRemote extends Remote { imgUrl: playbackState.item.album.images[0].url, title: playbackState.item.name, url: playbackState.item.external_urls.spotify, + updated_at: Date.now(), }, error: null, }; @@ -170,6 +171,11 @@ export default class SpotifyRemote extends Remote { await this.spotifyClient.player.skipToNext(state.device.id); return { data: undefined, error: null }; } + + async addToQueue(trackId: string): Promise> { + await this.spotifyClient.player.addItemToPlaybackQueue(trackId); + return { data: undefined, error: null }; + } } const extractArtistsName = (artists: SimplifiedArtist[]) => { From f60eb4df88fe4c108d3a348028d5bf7b92ad936c Mon Sep 17 00:00:00 2001 From: MAXOUXAX <24844231+MAXOUXAX@users.noreply.github.com> Date: Tue, 13 Feb 2024 17:57:36 +0100 Subject: [PATCH 14/24] fix(backend): removed useless return reply --- backend/src/route/RoomPOST.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/src/route/RoomPOST.ts b/backend/src/route/RoomPOST.ts index 10ee6721..3d1d48f6 100644 --- a/backend/src/route/RoomPOST.ts +++ b/backend/src/route/RoomPOST.ts @@ -48,6 +48,4 @@ export default async function RoomPOST( reply, req ); - - return reply; } From 63de905020be20a40b2c16b981b282b4010638cb Mon Sep 17 00:00:00 2001 From: MAXOUXAX <24844231+MAXOUXAX@users.noreply.github.com> Date: Tue, 13 Feb 2024 17:57:54 +0100 Subject: [PATCH 15/24] feat(backend): added playbackState and previousPlaybackState for room --- backend/src/socketio/Room.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/backend/src/socketio/Room.ts b/backend/src/socketio/Room.ts index 6594a87f..fcb6decb 100644 --- a/backend/src/socketio/Room.ts +++ b/backend/src/socketio/Room.ts @@ -1,4 +1,4 @@ -import { JSONTrack, RoomJSON } from "commons/backend-types"; +import { JSONTrack, PlayingJSONTrack, RoomJSON } from "commons/backend-types"; import { Socket } from "socket.io"; import RoomStorage from "../RoomStorage"; import MusicPlatform from "../musicplatform/MusicPlatform"; @@ -17,6 +17,8 @@ export default class Room { private hostSocket: Socket | null; private voteSkipActualTrack: string[] = []; private participants: string[] = []; + private playbackState: PlayingJSONTrack | null = null; + private previousPlaybackState: typeof this.playbackState = this.playbackState; private constructor( uuid: string, @@ -229,4 +231,17 @@ export default class Room { if (!data) return; this.participants = data.map((value) => value.profile_id); } + + setPlaybackState(newPlaybackState: typeof this.playbackState) { + this.previousPlaybackState = this.playbackState; + this.playbackState = newPlaybackState; + } + + getPlaybackState(): typeof this.playbackState { + return this.playbackState; + } + + getPreviousPlaybackState(): typeof this.previousPlaybackState { + return this.previousPlaybackState; + } } From 2688c5a43803a8a94c842a654458f50cc8b55ae0 Mon Sep 17 00:00:00 2001 From: MAXOUXAX <24844231+MAXOUXAX@users.noreply.github.com> Date: Tue, 13 Feb 2024 17:58:14 +0100 Subject: [PATCH 16/24] refactor(backend): moved playback controller loop inside RoomStorage --- backend/src/RoomStorage.ts | 67 +++++++++++++++++++++++++++++++++++++- backend/src/server.ts | 23 +------------ 2 files changed, 67 insertions(+), 23 deletions(-) diff --git a/backend/src/RoomStorage.ts b/backend/src/RoomStorage.ts index c714255f..38d85035 100644 --- a/backend/src/RoomStorage.ts +++ b/backend/src/RoomStorage.ts @@ -3,15 +3,17 @@ 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 { adminSupabase, server } from "./server"; import Room from "./socketio/Room"; import { RoomWithForeignTable } from "./socketio/RoomDatabase"; +import SpotifyRemote from "./musicplatform/remotes/SpotifyRemote"; const STREAMING_SERVICES = { Spotify: "a2d17b25-d87e-42af-9e79-fd4df6b59222", 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) { @@ -29,14 +31,77 @@ function getMusicPlatform(serviceId: string): MusicPlatform | null { export default class RoomStorage { private static singleton: RoomStorage; private readonly data: Map; + private readonly endingSoon: Map; private constructor() { this.data = new Map(); + this.endingSoon = new Map(); + } + + startTimer() { + setInterval(async () => { + const allRooms = await RoomStorage.getRoomStorage().getRooms(); + allRooms.forEach(async (room) => { + const remote = room.getRemote(); + if (!remote) return; + + const playbackState = room.getPlaybackState(); + // Avoid spamming REST APIs + if ( + playbackState !== null && + !room.getStreamingService().isClientSide() + ) { + if (Date.now() - playbackState.updated_at < 5000) return; + } + + const newPlaybackState = await remote.getPlaybackState(); + const music = newPlaybackState.data; + + server.io + .of(`/room/${room.uuid}`) + .emit("player:updatePlaybackState", newPlaybackState); + room.setPlaybackState(newPlaybackState.data); + + if (!music) return; + + const previousPlaybackState = room.getPreviousPlaybackState(); + + const remainingTime = music.duration - music.currentTime; + const hasTriggeredEndingSoonValue = this.endingSoon.get(room.uuid); + + if ( + remainingTime < MUSIC_ENDING_SOON_DELAY && + !hasTriggeredEndingSoonValue + ) { + this.endingSoon.set(room.uuid, true); + + setTimeout(() => { + if (!newPlaybackState.data?.isPlaying) return; + + const nextTrack = room.getQueue().shift(); + if (!nextTrack) return; + console.log("Playing next track: ", nextTrack); + remote.playTrack(nextTrack.url); + }, remainingTime); + } + if (music.url != previousPlaybackState?.url) { + const nextTrack = room.getQueue().shift(); + this.endingSoon.set(room.uuid, false); + + if (remote instanceof SpotifyRemote) { + if (!nextTrack) return; + + remote.addToQueue(nextTrack.url); + } + } + }); + }, 1000); } static getRoomStorage(): RoomStorage { if (this.singleton === undefined) { this.singleton = new RoomStorage(); + this.singleton.startTimer(); } return this.singleton; } diff --git a/backend/src/server.ts b/backend/src/server.ts index 422c0482..29e7350a 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -14,7 +14,6 @@ import { config } from "dotenv"; import fastify from "fastify"; import fastifySocketIO from "fastify-socket.io"; import { Server } from "socket.io"; -import RoomStorage from "./RoomStorage"; import authRoutes from "./authRoutes"; import RoomGET from "./route/RoomGET"; import RoomIdGET from "./route/RoomIdGET"; @@ -44,7 +43,7 @@ const corsOrigin: (devValue?: string | boolean) => (string | boolean)[] = ( return ["https://datsmysong.app", "https://api.datsmysong.app/"]; }; -const server = fastify({ +export const server = fastify({ logger: { transport: { target: "pino-pretty", @@ -182,26 +181,6 @@ server.ready().then(() => { server.io.of(/^\/room\/.*$/i).on("connection", RoomIO); }); -let ignoreCount = 1; - -setInterval(async () => { - const allRooms = await RoomStorage.getRoomStorage().getRooms(); - allRooms.forEach(async (room) => { - ignoreCount++; - if (!room.getStreamingService().isClientSide() && ignoreCount % 5 !== 0) - return; - if (!room.getStreamingService().isClientSide()) ignoreCount = 0; - - const remote = room.getRemote(); - if (!remote) return; - - const playbackState = await remote.getPlaybackState(); - server.io - .of(`/room/${room.uuid}`) - .emit("player:updatePlaybackState", playbackState); - }); -}, 1000); - server.listen({ port: 3000, host: "0.0.0.0" }); declare module "fastify" { From e8b33d0d33a267204ac5638376c1b363e3e50817 Mon Sep 17 00:00:00 2001 From: MAXOUXAX <24844231+MAXOUXAX@users.noreply.github.com> Date: Wed, 14 Feb 2024 11:44:59 +0100 Subject: [PATCH 17/24] feat(backend): when the queue is empty, immediately play the track when a track is added to the queue --- backend/src/socketio/RoomIO.ts | 16 ++++++++++++++++ commons/socket.io-types.ts | 2 ++ 2 files changed, 18 insertions(+) diff --git a/backend/src/socketio/RoomIO.ts b/backend/src/socketio/RoomIO.ts index 6d437814..ca7cbbcd 100644 --- a/backend/src/socketio/RoomIO.ts +++ b/backend/src/socketio/RoomIO.ts @@ -63,6 +63,22 @@ export default function RoomIO( sendQueueUpdated(); }); + socket.on("queue:add", async (rawUrl: string) => { + const queue = room.getQueue(); + // If the queue is currently empty, we can play the track immediately + if (queue.length === 0) { + const remote = room.getRemote(); + if (remote === null) return; + const response = await remote.playTrack(rawUrl); + if (!response.error) await updatePlaybackState(socket, remote); + return; + } + + // Otherwise, we just add the track to the queue + await room.add(rawUrl); + roomSocket.emit("queue:update", Room.toJSON(room)); + }); + // We should check the origin of the request to prevent anyone that isn't the host from removing anything socket.on("queue:remove", async (index: number) => { if (Number.isSafeInteger(index)) { diff --git a/commons/socket.io-types.ts b/commons/socket.io-types.ts index b65d6f8d..1232e7d8 100644 --- a/commons/socket.io-types.ts +++ b/commons/socket.io-types.ts @@ -82,6 +82,8 @@ export interface ClientToServerEvents "queue:remove": (index: number) => void; /** Remove a track from the queue by its link. */ "queue:removeLink": (link: string) => void; + // we found a better way to get the user id + "queue:voteSkip": (index: number, userid: string) => void; /** Search for tracks. */ "utils:search": ( text: string, From a9ac92c6979c6da60538159d64ece18892b692a0 Mon Sep 17 00:00:00 2001 From: MAXOUXAX <24844231+MAXOUXAX@users.noreply.github.com> Date: Wed, 14 Feb 2024 11:45:45 +0100 Subject: [PATCH 18/24] fix(backend): only start the track immediately if none is playing --- backend/src/socketio/RoomIO.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/socketio/RoomIO.ts b/backend/src/socketio/RoomIO.ts index ca7cbbcd..2aabb8e8 100644 --- a/backend/src/socketio/RoomIO.ts +++ b/backend/src/socketio/RoomIO.ts @@ -65,8 +65,9 @@ export default function RoomIO( socket.on("queue:add", async (rawUrl: string) => { const queue = room.getQueue(); + const playbackState = room.getPlaybackState(); // If the queue is currently empty, we can play the track immediately - if (queue.length === 0) { + if (queue.length === 0 && playbackState === null) { const remote = room.getRemote(); if (remote === null) return; const response = await remote.playTrack(rawUrl); From bf6c702e0b1d24c0a6a1c321bcb604717c17b532 Mon Sep 17 00:00:00 2001 From: MAXOUXAX <24844231+MAXOUXAX@users.noreply.github.com> Date: Wed, 21 Feb 2024 15:59:11 +0100 Subject: [PATCH 19/24] feat: improve spotify errors handling, added music logic, queue handling, and much more --- backend/src/RoomStorage.ts | 68 +++--- backend/src/musicplatform/Deezer.ts | 2 +- backend/src/musicplatform/MusicPlatform.ts | 2 +- backend/src/musicplatform/SoundCloud.ts | 2 +- backend/src/musicplatform/Spotify.ts | 2 +- backend/src/musicplatform/remotes/Remote.ts | 8 +- .../musicplatform/remotes/SoundCloudRemote.ts | 8 +- .../musicplatform/remotes/SpotifyRemote.ts | 206 ++++++++++-------- backend/src/socketio/Room.ts | 58 ++++- backend/src/socketio/RoomIO.ts | 69 +++--- expo/components/ActiveRoomView.tsx | 97 ++++++--- expo/components/Warning.tsx | 42 +++- expo/components/player/RoomPlayer.tsx | 8 +- expo/components/profile/AvatarForm.tsx | 1 - expo/lib/soundcloud-widget-html.tsx | 19 +- expo/lib/useNetworkStatus.ts | 20 ++ expo/package-lock.json | 71 +++--- expo/package.json | 3 +- 18 files changed, 417 insertions(+), 269 deletions(-) create mode 100644 expo/lib/useNetworkStatus.ts diff --git a/backend/src/RoomStorage.ts b/backend/src/RoomStorage.ts index 38d85035..d9673be8 100644 --- a/backend/src/RoomStorage.ts +++ b/backend/src/RoomStorage.ts @@ -6,7 +6,7 @@ import Spotify from "./musicplatform/Spotify"; import { adminSupabase, server } from "./server"; import Room from "./socketio/Room"; import { RoomWithForeignTable } from "./socketio/RoomDatabase"; -import SpotifyRemote from "./musicplatform/remotes/SpotifyRemote"; +import { QueueableRemote } from "./musicplatform/remotes/Remote"; const STREAMING_SERVICES = { Spotify: "a2d17b25-d87e-42af-9e79-fd4df6b59222", @@ -31,11 +31,11 @@ function getMusicPlatform(serviceId: string): MusicPlatform | null { export default class RoomStorage { private static singleton: RoomStorage; private readonly data: Map; - private readonly endingSoon: Map; + private readonly startedTimer: Map; private constructor() { this.data = new Map(); - this.endingSoon = new Map(); + this.startedTimer = new Map(); } startTimer() { @@ -45,53 +45,69 @@ export default class RoomStorage { const remote = room.getRemote(); if (!remote) return; - const playbackState = room.getPlaybackState(); + const lastKnownPlaybackState = room.getPlaybackState(); // Avoid spamming REST APIs if ( - playbackState !== null && + lastKnownPlaybackState !== null && !room.getStreamingService().isClientSide() ) { - if (Date.now() - playbackState.updated_at < 5000) return; + if (Date.now() - lastKnownPlaybackState.updated_at < 5000) return; } - const newPlaybackState = await remote.getPlaybackState(); - const music = newPlaybackState.data; - + // Fetching newest playback state and sending it to the room + const newPlaybackStateResponse = await remote.getPlaybackState(); server.io .of(`/room/${room.uuid}`) - .emit("player:updatePlaybackState", newPlaybackState); - room.setPlaybackState(newPlaybackState.data); + .emit("player:updatePlaybackState", newPlaybackStateResponse); - if (!music) return; + const newPlaybackState = newPlaybackStateResponse.data; + room.setPlaybackState(newPlaybackState); - const previousPlaybackState = room.getPreviousPlaybackState(); + if (!newPlaybackState) return console.log("No music is playing"); - const remainingTime = music.duration - music.currentTime; - const hasTriggeredEndingSoonValue = this.endingSoon.get(room.uuid); + const remainingTime = + newPlaybackState.duration - newPlaybackState.currentTime; + const hasTriggeredEndingSoonValue = this.startedTimer.get(room.uuid); if ( remainingTime < MUSIC_ENDING_SOON_DELAY && - !hasTriggeredEndingSoonValue + !hasTriggeredEndingSoonValue && + !(remote instanceof QueueableRemote) ) { - this.endingSoon.set(room.uuid, true); + console.log( + `The track of the room ${room.uuid} is ending soon, and it doesn't support queueing` + ); + this.startedTimer.set(room.uuid, true); setTimeout(() => { - if (!newPlaybackState.data?.isPlaying) return; + if (!newPlaybackStateResponse.data?.isPlaying) + return console.log("The track is not playing anymore"); + this.startedTimer.set(room.uuid, false); + + const nextTrack = room.shiftQueue(); + if (!nextTrack) return console.log("No more tracks in the queue"); - const nextTrack = room.getQueue().shift(); - if (!nextTrack) return; - console.log("Playing next track: ", nextTrack); remote.playTrack(nextTrack.url); + console.log(`The player will now play ${nextTrack.url}`); }, remainingTime); } - if (music.url != previousPlaybackState?.url) { - const nextTrack = room.getQueue().shift(); - this.endingSoon.set(room.uuid, false); - if (remote instanceof SpotifyRemote) { - if (!nextTrack) return; + 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.log(`The track of room ${room.uuid} has changed`); + + if (remote instanceof QueueableRemote) { + let nextTrack = room.getQueue().at(0); + + if (nextTrack?.url === newPlaybackState.url) { + nextTrack = room.shiftQueue(); + } + if (!nextTrack) return console.log("No more tracks in the queue"); remote.addToQueue(nextTrack.url); + console.log(`Just added ${nextTrack.url} to the queue`); } } }); diff --git a/backend/src/musicplatform/Deezer.ts b/backend/src/musicplatform/Deezer.ts index e0ddc499..a4de5cb5 100644 --- a/backend/src/musicplatform/Deezer.ts +++ b/backend/src/musicplatform/Deezer.ts @@ -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 { diff --git a/backend/src/musicplatform/MusicPlatform.ts b/backend/src/musicplatform/MusicPlatform.ts index d71b947e..a95c94aa 100644 --- a/backend/src/musicplatform/MusicPlatform.ts +++ b/backend/src/musicplatform/MusicPlatform.ts @@ -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 { diff --git a/backend/src/musicplatform/SoundCloud.ts b/backend/src/musicplatform/SoundCloud.ts index 90eed9f4..ea215d77 100644 --- a/backend/src/musicplatform/SoundCloud.ts +++ b/backend/src/musicplatform/SoundCloud.ts @@ -1,7 +1,7 @@ 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"; diff --git a/backend/src/musicplatform/Spotify.ts b/backend/src/musicplatform/Spotify.ts index fc6b83cf..e48336b6 100644 --- a/backend/src/musicplatform/Spotify.ts +++ b/backend/src/musicplatform/Spotify.ts @@ -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"; diff --git a/backend/src/musicplatform/remotes/Remote.ts b/backend/src/musicplatform/remotes/Remote.ts index e795a49b..d132fc7c 100644 --- a/backend/src/musicplatform/remotes/Remote.ts +++ b/backend/src/musicplatform/remotes/Remote.ts @@ -1,9 +1,8 @@ import { JSONTrack, PlayingJSONTrack } from "commons/backend-types"; import { Response } from "commons/socket.io-types"; -export default abstract class Remote { +export abstract class Remote { abstract getPlaybackState(): Promise>; - abstract getQueue(): Promise>; abstract playTrack(trackId: string): Promise>; abstract setVolume(volume: number): Promise>; abstract seekTo(position: number): Promise>; @@ -12,3 +11,8 @@ export default abstract class Remote { abstract previous(): Promise>; abstract next(): Promise>; } + +export abstract class QueueableRemote extends Remote { + abstract addToQueue(trackId: string): Promise>; + abstract getQueue(): Promise>; +} diff --git a/backend/src/musicplatform/remotes/SoundCloudRemote.ts b/backend/src/musicplatform/remotes/SoundCloudRemote.ts index f680547d..e6b49d74 100644 --- a/backend/src/musicplatform/remotes/SoundCloudRemote.ts +++ b/backend/src/musicplatform/remotes/SoundCloudRemote.ts @@ -1,6 +1,6 @@ import { JSONTrack, PlayingJSONTrack } from "commons/backend-types"; import MusicPlatform from "../MusicPlatform"; -import Remote from "./Remote"; +import { Remote } from "./Remote"; import { LocalPlayerServerToClientEvents, Response, @@ -44,7 +44,7 @@ export default class SoundCloudRemote extends Remote { }; hostSocket.on(event, listener); - hostSocket.emit(event, data); + hostSocket.emit(event, data as never); }); } @@ -54,10 +54,6 @@ export default class SoundCloudRemote extends Remote { ); } - async getQueue(): Promise> { - return this.emitAndListen("player:getQueueRequest"); - } - async playTrack(trackId: string): Promise> { return this.emitAndListen("player:playTrackRequest", trackId); } diff --git a/backend/src/musicplatform/remotes/SpotifyRemote.ts b/backend/src/musicplatform/remotes/SpotifyRemote.ts index fefa95ca..de1a7924 100644 --- a/backend/src/musicplatform/remotes/SpotifyRemote.ts +++ b/backend/src/musicplatform/remotes/SpotifyRemote.ts @@ -2,11 +2,11 @@ import { SimplifiedArtist, SpotifyApi, Track } from "@spotify/web-api-ts-sdk"; import { JSONTrack, PlayingJSONTrack } from "commons/backend-types"; import { adminSupabase } from "../../server"; import MusicPlatform from "../MusicPlatform"; -import Remote from "./Remote"; +import { QueueableRemote } from "./Remote"; import Room from "../../socketio/Room"; import { Response } from "commons/socket.io-types"; -export default class SpotifyRemote extends Remote { +export default class SpotifyRemote extends QueueableRemote { spotifyClient: SpotifyApi; musicPlatform: MusicPlatform; @@ -44,6 +44,7 @@ export default class SpotifyRemote extends Remote { const expiresIn = parseInt(expires_in); + // TODO: https://github.com/spotify/spotify-web-api-ts-sdk/issues/79 const spotifyClient = SpotifyApi.withAccessToken( process.env.SPOTIFY_CLIENT_ID as string, { @@ -57,124 +58,145 @@ export default class SpotifyRemote extends Remote { } async getPlaybackState(): Promise> { - const spotifyPlaybackState = - await this.spotifyClient.player.getPlaybackState(); - - if (!spotifyPlaybackState || spotifyPlaybackState.item.type === "episode") - return { data: null, error: "No track is currently playing" }; - - const playbackState = { - ...spotifyPlaybackState, - item: spotifyPlaybackState.item as Track, - }; - - const artistsName = extractArtistsName(playbackState.item.album.artists); - - return { - data: { - isPlaying: playbackState.is_playing, - albumName: playbackState.item.album.name, - artistsName: artistsName, - currentTime: playbackState.progress_ms, - duration: playbackState.item.duration_ms, - imgUrl: playbackState.item.album.images[0].url, - title: playbackState.item.name, - url: playbackState.item.external_urls.spotify, - updated_at: Date.now(), - }, - error: null, - }; + return runSpotifyCallback(async () => { + const spotifyPlaybackState = + await this.spotifyClient.player.getPlaybackState(); + + if (!spotifyPlaybackState || spotifyPlaybackState.item.type === "episode") + return { data: null, error: "No track is currently playing" }; + + const playbackState = { + ...spotifyPlaybackState, + item: spotifyPlaybackState.item as Track, + }; + + const artistsName = extractArtistsName(playbackState.item.album.artists); + + return { + data: { + isPlaying: playbackState.is_playing, + albumName: playbackState.item.album.name, + artistsName: artistsName, + currentTime: playbackState.progress_ms, + duration: playbackState.item.duration_ms, + imgUrl: playbackState.item.album.images[0].url, + title: playbackState.item.name, + url: playbackState.item.external_urls.spotify, + updated_at: Date.now(), + }, + error: null, + }; + }); } async getQueue(): Promise> { - const spotifyQueue = await this.spotifyClient.player.getUsersQueue(); - - const queue = spotifyQueue.queue - .filter((item) => item.type === "track") - .map((item) => item as Track) - .map((item) => { - return { - title: item.name, - albumName: item.album.name, - artistsName: extractArtistsName(item.album.artists), - duration: item.duration_ms, - imgUrl: item.album.images[0].url, - url: item.external_urls.spotify, - }; - }); - - return { data: queue, error: null }; + return runSpotifyCallback(async () => { + const spotifyQueue = await this.spotifyClient.player.getUsersQueue(); + + const queue = spotifyQueue.queue + .filter((item) => item.type === "track") + .map((item) => item as Track) + .map((item) => { + return { + title: item.name, + albumName: item.album.name, + artistsName: extractArtistsName(item.album.artists), + duration: item.duration_ms, + imgUrl: item.album.images[0].url, + url: item.external_urls.spotify, + }; + }); + + return { data: queue, error: null }; + }); } async playTrack(trackId: string): Promise> { - const state = await this.spotifyClient.player.getPlaybackState(); + return runSpotifyCallback(async () => { + const state = await this.spotifyClient.player.getPlaybackState(); - if (!state || !state.device.id) { - return { data: null, error: "No device found" }; - } + if (!state || !state.device.id) { + return { data: null, error: "No device found" }; + } - await this.spotifyClient.player.startResumePlayback( - state.device.id, - undefined, - [`${trackId}`] - ); + await this.spotifyClient.player.startResumePlayback( + state.device.id, + undefined, + [`${trackId}`] + ); - return { data: undefined, error: null }; + return { data: undefined, error: null }; + }); } async setVolume(volume: number): Promise> { - await this.spotifyClient.player.setPlaybackVolume(volume); - return { data: undefined, error: null }; + return runSpotifyCallback(async () => { + await this.spotifyClient.player.setPlaybackVolume(volume); + return { data: undefined, error: null }; + }); } async seekTo(position: number): Promise> { - await this.spotifyClient.player.seekToPosition(position); - return { data: undefined, error: null }; + position = Math.floor(position); + return runSpotifyCallback(async () => { + await this.spotifyClient.player.seekToPosition(position); + return { data: undefined, error: null }; + }); } async play(): Promise> { - const state = await this.spotifyClient.player.getPlaybackState(); - if (!state || !state.device.id) { - return { data: null, error: "No device found" }; - } + return runSpotifyCallback(async () => { + const state = await this.spotifyClient.player.getPlaybackState(); + if (!state || !state.device.id) { + return { data: null, error: "No device found" }; + } - await this.spotifyClient.player.startResumePlayback(state.device.id); - return { data: undefined, error: null }; + await this.spotifyClient.player.startResumePlayback(state.device.id); + return { data: undefined, error: null }; + }); } async pause(): Promise> { - const state = await this.spotifyClient.player.getPlaybackState(); - if (!state || !state.device.id) { - return { data: null, error: "No device found" }; - } + return runSpotifyCallback(async () => { + const state = await this.spotifyClient.player.getPlaybackState(); + if (!state || !state.device.id) { + return { data: null, error: "No device found" }; + } - await this.spotifyClient.player.pausePlayback(state.device.id); - return { data: undefined, error: null }; + await this.spotifyClient.player.pausePlayback(state.device.id); + return { data: undefined, error: null }; + }); } async previous(): Promise> { - const state = await this.spotifyClient.player.getPlaybackState(); - if (!state || !state.device.id) { - return { data: null, error: "No device found" }; - } + return runSpotifyCallback(async () => { + const state = await this.spotifyClient.player.getPlaybackState(); + if (!state || !state.device.id) { + return { data: null, error: "No device found" }; + } - await this.spotifyClient.player.skipToPrevious(state.device.id); - return { data: undefined, error: null }; + await this.spotifyClient.player.skipToPrevious(state.device.id); + return { data: undefined, error: null }; + }); } async next(): Promise> { - const state = await this.spotifyClient.player.getPlaybackState(); - if (!state || !state.device.id) { - return { data: null, error: "No device found" }; - } + return runSpotifyCallback(async () => { + const state = await this.spotifyClient.player.getPlaybackState(); + if (!state || !state.device.id) { + return { data: null, error: "No device found" }; + } - await this.spotifyClient.player.skipToNext(state.device.id); - return { data: undefined, error: null }; + await this.spotifyClient.player.skipToNext(state.device.id); + return { data: undefined, error: null }; + }); } async addToQueue(trackId: string): Promise> { - await this.spotifyClient.player.addItemToPlaybackQueue(trackId); - return { data: undefined, error: null }; + return runSpotifyCallback(async () => { + await this.spotifyClient.player.addItemToPlaybackQueue(trackId); + return { data: undefined, error: null }; + }); } } @@ -184,3 +206,15 @@ const extractArtistsName = (artists: SimplifiedArtist[]) => { "" ); }; + +function runSpotifyCallback( + callback: () => Promise> +): Promise> { + return callback().catch((e: unknown) => { + console.error(e); + if (e instanceof Error) { + return { data: null, error: e.message }; + } + return { data: null, error: "An unknown error occurred" }; + }); +} diff --git a/backend/src/socketio/Room.ts b/backend/src/socketio/Room.ts index fcb6decb..14eca696 100644 --- a/backend/src/socketio/Room.ts +++ b/backend/src/socketio/Room.ts @@ -3,9 +3,15 @@ import { Socket } from "socket.io"; import RoomStorage from "../RoomStorage"; import MusicPlatform from "../musicplatform/MusicPlatform"; import TrackFactory from "../musicplatform/TrackFactory"; -import Remote from "../musicplatform/remotes/Remote"; import { RoomWithConfigDatabase } from "./RoomDatabase"; import { adminSupabase } from "../server"; +import { QueueableRemote, Remote } from "../musicplatform/remotes/Remote"; +import { + ClientToServerEvents, + ServerToClientEvents, +} from "commons/socket.io-types"; + +export type TypedSocket = Socket; export default class Room { public readonly uuid: string; @@ -14,9 +20,9 @@ export default class Room { private readonly streamingService: MusicPlatform; private readonly room: RoomWithConfigDatabase; private remote: Remote | null = null; - private hostSocket: Socket | null; private voteSkipActualTrack: string[] = []; private participants: string[] = []; + private hostSocket: TypedSocket | null; private playbackState: PlayingJSONTrack | null = null; private previousPlaybackState: typeof this.playbackState = this.playbackState; @@ -82,17 +88,30 @@ export default class Room { } async add(rawUrl: string) { - const trackMetadata = this.trackFactory.fromUrl(rawUrl); - if (trackMetadata !== null) { - const track = await trackMetadata.toJSON(); - if (track !== null) { - if (!this.queue.map((value) => value.url).includes(track.url)) { - this.queue.push(track); - return true; - } - } + if (!this.remote) return; + + // If the queue is currently empty and no track is playing, we can play the track immediately + const playbackState = this.remote.getPlaybackState(); + if (this.queue.length === 0 && playbackState === null) { + const response = await this.remote.playTrack(rawUrl); + if (!response.error) await this.updatePlaybackState(); + return; } - return false; + + const trackMetadata = this.trackFactory.fromUrl(rawUrl); + if (trackMetadata === null) return; + + const track = await trackMetadata.toJSON(); + if (track === null) return; + + if (this.queue.map((value) => value.url).includes(track.url)) return; + + this.queue.push(track); + if (this.queue.length !== 1) return; + + // For remote streaming services, we should add the track to the queue of the player + if (!(this.remote instanceof QueueableRemote)) return; + (this.remote as QueueableRemote).addToQueue(track.url); } async removeWithLink(rawUrl: string) { @@ -244,4 +263,19 @@ export default class Room { getPreviousPlaybackState(): typeof this.previousPlaybackState { return this.previousPlaybackState; } + + shiftQueue() { + const result = this.queue.shift(); + this.hostSocket?.nsp.emit("queue:update", Room.toJSON(this)); + return result; + } + + async updatePlaybackState() { + if (this.remote === null) return; + const playbackState = await this.remote.getPlaybackState(); + if (playbackState.data === null) return; + + this.setPlaybackState(playbackState.data); + this.hostSocket?.nsp.emit("player:updatePlaybackState", playbackState); + } } diff --git a/backend/src/socketio/RoomIO.ts b/backend/src/socketio/RoomIO.ts index 2aabb8e8..510ce5f2 100644 --- a/backend/src/socketio/RoomIO.ts +++ b/backend/src/socketio/RoomIO.ts @@ -1,20 +1,12 @@ -import { - ClientToServerEvents, - ServerToClientEvents, -} from "commons/socket.io-types"; -import { Socket } from "socket.io"; +import { Response } from "commons/socket.io-types"; import RoomStorage from "../RoomStorage"; -import Remote from "../musicplatform/remotes/Remote"; -import Room from "./Room"; import { JSONTrack } from "commons/backend-types"; +import Room, { TypedSocket } from "./Room"; const roomStorage = RoomStorage.getRoomStorage(); export default function RoomIO( - socket: Socket< - ClientToServerEvents, - ServerToClientEvents - > /*, next: (err?: ExtendedError) => void*/ + socket: TypedSocket /*, next: (err?: ExtendedError) => void*/ ) { const roomSocket = socket.nsp; /*regex uuid [0-9a-f]{8}-([0-9a-f]{4}){3}-[0-9a-f]{12}*/ @@ -64,18 +56,6 @@ export default function RoomIO( }); socket.on("queue:add", async (rawUrl: string) => { - const queue = room.getQueue(); - const playbackState = room.getPlaybackState(); - // If the queue is currently empty, we can play the track immediately - if (queue.length === 0 && playbackState === null) { - const remote = room.getRemote(); - if (remote === null) return; - const response = await remote.playTrack(rawUrl); - if (!response.error) await updatePlaybackState(socket, remote); - return; - } - - // Otherwise, we just add the track to the queue await room.add(rawUrl); roomSocket.emit("queue:update", Room.toJSON(room)); }); @@ -104,7 +84,7 @@ export default function RoomIO( if (skipped === "actualTrackSkiped") { const remote = room.getRemote(); if (!remote) return; - updatePlaybackState(socket, remote); + room.updatePlaybackState(); } }); @@ -119,7 +99,7 @@ export default function RoomIO( const response = await remote.playTrack(trackId); socket.emit("player:playTrack", response); - if (!response.error) await updatePlaybackState(socket, remote); + if (!response.error) await room.updatePlaybackState(); }); socket.on("player:pause", async () => { @@ -129,7 +109,7 @@ export default function RoomIO( const response = await remote.pause(); socket.emit("player:pause", response); - if (!response.error) await updatePlaybackState(socket, remote); + if (!response.error) await room.updatePlaybackState(); }); socket.on("player:play", async () => { @@ -137,19 +117,35 @@ export default function RoomIO( if (remote === null) return; const response = await remote.play(); - if (!response.error) await updatePlaybackState(socket, remote); + if (!response.error) await room.updatePlaybackState(); socket.emit("player:play", response); }); socket.on("player:skip", async () => { const remote = room.getRemote(); - if (remote === null) return; + if (remote === null) + return socket.emit("player:skip", { + error: "No remote", + data: null, + }); + + let response: Response; + const nextTrack = room.shiftQueue(); + if (!nextTrack) + return socket.emit("player:skip", { + error: "No track to skip to", + data: null, + }); + + if (room.getStreamingService().isClientSide()) { + response = await remote.playTrack(nextTrack.url); + } else { + response = await remote.next(); + } - const response = await remote.next(); socket.emit("player:skip", response); - - if (!response.error) await updatePlaybackState(socket, remote); + if (!response.error) await room.updatePlaybackState(); }); socket.on("player:previous", async () => { @@ -159,7 +155,7 @@ export default function RoomIO( const response = await remote.previous(); socket.emit("player:previous", response); - if (!response.error) await updatePlaybackState(socket, remote); + if (!response.error) await room.updatePlaybackState(); }); socket.on("player:setVolume", async (volume) => { @@ -177,7 +173,7 @@ export default function RoomIO( const response = await remote.seekTo(position); socket.emit("player:seekTo", response); - if (!response.error) await updatePlaybackState(socket, remote); + if (!response.error) await room.updatePlaybackState(); }); socket.on( @@ -192,10 +188,3 @@ export default function RoomIO( registerHandlers(); } - -async function updatePlaybackState(socket: Socket, remote: Remote) { - setTimeout(async () => { - const playbackState = await remote.getPlaybackState(); - socket.nsp.emit("player:updatePlaybackState", playbackState); - }, 100); -} diff --git a/expo/components/ActiveRoomView.tsx b/expo/components/ActiveRoomView.tsx index e5361b88..4cd470ca 100644 --- a/expo/components/ActiveRoomView.tsx +++ b/expo/components/ActiveRoomView.tsx @@ -2,17 +2,24 @@ import { MaterialIcons } from "@expo/vector-icons"; import { RoomJSON } from "commons/Backend-types"; import { Link, router } from "expo-router"; import { useEffect, useState } from "react"; -import { FlatList, Platform, ScrollView, StyleSheet } from "react-native"; +import { + ActivityIndicator, + FlatList, + Platform, + StyleSheet, +} from "react-native"; import Alert from "./Alert"; import Button from "./Button"; import Confirm from "./Confirm"; import { Text, View } from "./Themed"; +import Warning from "./Warning"; import RoomPlayer from "./player/RoomPlayer"; import TrackItem from "./room/TrackItem"; import { useWebSocket } from "../app/(tabs)/rooms/[id]/_layout"; import { getApiUrl } from "../lib/apiUrl"; import { getRoomHostedByUser } from "../lib/room-utils"; +import useNetworkStatus from "../lib/useNetworkStatus"; import { ActiveRoom } from "../lib/useRoom"; import { useUserProfile } from "../lib/userProfile"; @@ -128,31 +135,56 @@ const ActiveRoomView: React.FC = ({ room }) => { } }; + const networkStatus = useNetworkStatus(); + return ( - <> - - {room && socket && ( - - - Salle "{room.name}" - {isHost ? ( - - - - ) : ( + + {!networkStatus && } + + {socket && !socket.connected && ( + + Connexion au serveur... + + + )} + {room && liveRoom && socket && socket.connected && ( + <> + + Salle "{room.name}" + {isHost ? ( + - )} - + + ) : ( + + )} - + + + + )} + ); }; diff --git a/expo/components/Warning.tsx b/expo/components/Warning.tsx index 0272aa3a..2de88f83 100644 --- a/expo/components/Warning.tsx +++ b/expo/components/Warning.tsx @@ -1,6 +1,32 @@ import { MaterialIcons } from "@expo/vector-icons"; import { StyleSheet, Text, View } from "react-native"; +type Variant = { + icon: keyof typeof MaterialIcons.glyphMap; + color: string; +}; + +type Variants = { + success: Variant; + warning: Variant; + error: Variant; +}; + +const variants: Variants = { + success: { + icon: "check-circle", + color: "green", + }, + warning: { + icon: "warning", + color: "red", + }, + error: { + icon: "error", + color: "red", + }, +}; + const Warning = ({ label, children, @@ -8,17 +34,14 @@ const Warning = ({ }: { label: string; children?: React.ReactNode; - variant?: "success" | "warning"; + variant?: keyof typeof variants; }) => { - const variantStyles = variant === "success" ? styles.success : styles.warning; + const variantStyles = styles[variant]; + const { icon: variantIcon, color: variantColorIcon } = variants[variant]; return ( - {variant === "success" ? ( - - ) : ( - - )} + = ({ room, socket }) => { if (!remote) return; if (room.streaming_services?.service_name === "Spotify") { - await remote.playTrack("spotify:track:44yeyFTKxJR5Rd9ppeKVkp"); + await remote.playTrack("spotify:track:6afdNrotJ1PCt9DoFiHpLj"); } else if (room.streaming_services?.service_name === "SoundCloud") { await remote.playTrack( - "https://soundcloud.com/dukeandjones/call-me-chill-mix" + "https://soundcloud.com/martingarrix/martin-garrix-lloyiso-real-love" ); } }; return ( <> - {error && } + {error && } {isHost && ( = ({ room, socket }) => { ); diff --git a/expo/components/profile/AvatarForm.tsx b/expo/components/profile/AvatarForm.tsx index 89b86fcc..26842192 100644 --- a/expo/components/profile/AvatarForm.tsx +++ b/expo/components/profile/AvatarForm.tsx @@ -52,7 +52,6 @@ const AvatarForm = forwardRef((props: AvatarProps, ref) => { }); if (error) { - console.error("error", error); return { error: error.message, }; diff --git a/expo/lib/soundcloud-widget-html.tsx b/expo/lib/soundcloud-widget-html.tsx index 5178f11d..c1f35712 100644 --- a/expo/lib/soundcloud-widget-html.tsx +++ b/expo/lib/soundcloud-widget-html.tsx @@ -53,18 +53,29 @@ export default function getSoundCloudWidgetHtml() { const playingMusic = await new Promise((resolve, reject) => { widget.getCurrentSound((currentSound) => { if (!currentSound) return resolve(null); + + let [title, artistsName, albumName] = [ + currentSound.publisher_metadata.release_title, + currentSound.publisher_metadata.artist, + currentSound.publisher_metadata.album_title + ]; + if (currentSound.publisher_metadata.release_title === undefined){ + title = currentSound.title.split(" - ")[1]; + artistsName = currentSound.title.split(" - ")[0]; + albumName = currentSound.title; + } const playingMusic = { url: currentSound.permalink_url, - title: currentSound.publisher_metadata.release_title, + title, duration: currentSound.duration, - artistsName: currentSound.publisher_metadata.artist, - albumName: currentSound.publisher_metadata.album_title, + artistsName, + albumName, imgUrl: currentSound.artwork_url.replace("large", "t500x500"), currentTime: position, isPlaying: isCurrentlyPlaying, }; - + resolve(playingMusic); }); }); diff --git a/expo/lib/useNetworkStatus.ts b/expo/lib/useNetworkStatus.ts new file mode 100644 index 00000000..a5e90fcb --- /dev/null +++ b/expo/lib/useNetworkStatus.ts @@ -0,0 +1,20 @@ +import NetInfo from "@react-native-community/netinfo"; +import { useEffect, useState } from "react"; + +export default function useNetworkStatus() { + const [isConnected, setIsConnected] = useState(true); + + useEffect(() => { + NetInfo.fetch().then((state) => { + setIsConnected(state.isConnected); + }); + + const unsubscribe = NetInfo.addEventListener((state) => { + setIsConnected(state.isConnected); + }); + + return () => unsubscribe(); + }); + + return isConnected; +} diff --git a/expo/package-lock.json b/expo/package-lock.json index c4a4cab3..78345761 100644 --- a/expo/package-lock.json +++ b/expo/package-lock.json @@ -12,13 +12,12 @@ "@expo/vector-icons": "^13.0.0", "@react-native-async-storage/async-storage": "1.18.2", "@react-native-community/checkbox": "^0.5.16", + "@react-native-community/netinfo": "9.3.10", "@react-native-community/slider": "4.4.2", "@react-navigation/native": "^6.0.2", "@spotify/web-api-ts-sdk": "^1.1.2", "@supabase/ssr": "^0.0.10", "@supabase/supabase-js": "^2.39.2", - "@types/uuid": "^9.0.7", - "@unimodules/core": "^7.1.2", "aes-js": "^3.1.2", "base64-arraybuffer": "^1.0.2", "expo": "~49.0.15", @@ -6170,10 +6169,16 @@ "version": "4.0.0", "license": "ISC" }, + "node_modules/@react-native-community/netinfo": { + "version": "9.3.10", + "license": "MIT", + "peerDependencies": { + "react-native": ">=0.59" + } + }, "node_modules/@react-native-community/slider": { "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@react-native-community/slider/-/slider-4.4.2.tgz", - "integrity": "sha512-D9bv+3Vd2gairAhnRPAghwccgEmoM7g562pm8i4qB3Esrms5mggF81G3UvCyc0w3jjtFHh8dpQkfEoKiP0NW/Q==" + "license": "MIT" }, "node_modules/@react-native/assets-registry": { "version": "0.72.0", @@ -6969,13 +6974,6 @@ "dev": true, "license": "ISC" }, - "node_modules/@unimodules/core": { - "version": "7.1.2", - "license": "MIT", - "dependencies": { - "compare-versions": "^3.4.0" - } - }, "node_modules/@urql/core": { "version": "2.3.6", "license": "MIT", @@ -10371,8 +10369,7 @@ }, "node_modules/expo-dev-client": { "version": "2.4.12", - "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-2.4.12.tgz", - "integrity": "sha512-3+xg0yb/0g6+JQaWq5+xn2uHoOXP4oSX33aWkaZPSNJLoyzfRaHNDF5MLcrMBbEHCw5T5qZRU291K+uQeMMC0g==", + "license": "MIT", "dependencies": { "expo-dev-launcher": "2.4.14", "expo-dev-menu": "3.2.2", @@ -10386,8 +10383,7 @@ }, "node_modules/expo-dev-launcher": { "version": "2.4.14", - "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-2.4.14.tgz", - "integrity": "sha512-SlUf+fEX9sKzDzY1Ui8j5775eLKpO0xPVoI89G7CRsrpUv6ZRvRF836cMFesxkU5d+3bXHpKzDQiEPDSI1G/WQ==", + "license": "MIT", "dependencies": { "expo-dev-menu": "3.2.2", "resolve-from": "^5.0.0", @@ -10399,8 +10395,7 @@ }, "node_modules/expo-dev-launcher/node_modules/lru-cache": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -10410,8 +10405,7 @@ }, "node_modules/expo-dev-launcher/node_modules/semver": { "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" }, @@ -10424,13 +10418,11 @@ }, "node_modules/expo-dev-launcher/node_modules/yallist": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "license": "ISC" }, "node_modules/expo-dev-menu": { "version": "3.2.2", - "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-3.2.2.tgz", - "integrity": "sha512-q0IDlCGkZMsDIFV+Mgnz0Q3u/bcnrF8IFMglJ0onF09e5csLk5Ts7hKoQyervOJeThyI402r9OQsFNaru2tgtg==", + "license": "MIT", "dependencies": { "expo-dev-menu-interface": "1.3.0", "semver": "^7.5.3" @@ -10441,16 +10433,14 @@ }, "node_modules/expo-dev-menu-interface": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expo-dev-menu-interface/-/expo-dev-menu-interface-1.3.0.tgz", - "integrity": "sha512-WtRP7trQ2lizJJTTFXUSGGn1deIeHaYej0sUynvu/uC69VrSP4EeSnYOxbmEO29kuT/MsQBMGu0P/AkMQOqCOg==", + "license": "MIT", "peerDependencies": { "expo": "*" } }, "node_modules/expo-dev-menu/node_modules/lru-cache": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -10460,8 +10450,7 @@ }, "node_modules/expo-dev-menu/node_modules/semver": { "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "license": "ISC", "dependencies": { "lru-cache": "^6.0.0" }, @@ -10474,8 +10463,7 @@ }, "node_modules/expo-dev-menu/node_modules/yallist": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "license": "ISC" }, "node_modules/expo-device": { "version": "5.4.0", @@ -10573,8 +10561,7 @@ }, "node_modules/expo-json-utils": { "version": "0.7.1", - "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.7.1.tgz", - "integrity": "sha512-L0lyH8diXQtV0q5BLbFlcoxTqPF5im79xDHPhybB0j36xYdm65hjwRJ4yMrPIN5lR18hj48FUZeONiDHRyEvIg==" + "license": "MIT" }, "node_modules/expo-keep-awake": { "version": "12.3.0", @@ -10596,8 +10583,7 @@ }, "node_modules/expo-manifests": { "version": "0.7.2", - "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-0.7.2.tgz", - "integrity": "sha512-xlhL0XI2zw3foJ0q2Ra4ieBhU0V2yz+Rv6GpVEaaIHFlIC/Dbx+mKrX5dgenZEMERr/MG7sRJaRbAVB2PaAYhA==", + "license": "MIT", "dependencies": { "expo-json-utils": "~0.7.0" } @@ -10788,8 +10774,7 @@ }, "node_modules/expo-updates-interface": { "version": "0.10.1", - "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-0.10.1.tgz", - "integrity": "sha512-I6JMR7EgjXwckrydDmrkBEX/iw750dcqpzQVsjznYWfi0HTEOxajLHB90fBFqQkUV5i5s4Fd3hYQ1Cn0oMzUbA==", + "license": "MIT", "peerDependencies": { "expo": "*" } @@ -14680,9 +14665,9 @@ "license": "0BSD" }, "node_modules/jschardet": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/jschardet/-/jschardet-3.0.0.tgz", - "integrity": "sha512-lJH6tJ77V8Nzd5QWRkFYCLc13a3vADkh3r/Fi8HupZGWk2OVVDfnZP8V/VgQgZ+lzW0kG2UGb5hFgt3V3ndotQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jschardet/-/jschardet-3.1.0.tgz", + "integrity": "sha512-MND0yjRsoQ/3iFXce7lqV/iBmqH9oWGUTlty36obRBZjhFDWCLKjXgfxY75wYfwlW7EFqw52tyziy/q4WsQmrA==", "dev": true, "engines": { "node": ">=0.1.90" @@ -17980,8 +17965,7 @@ }, "node_modules/react-native-webview": { "version": "13.2.2", - "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.2.2.tgz", - "integrity": "sha512-uT70y2GUqQzaj2RwRb/QuKRdXeDjXM6oN3DdPqYQlOOMFTCT8r62fybyjVVRoik8io+KLa5KnmuSoS5B2O1BmA==", + "license": "MIT", "dependencies": { "escape-string-regexp": "2.0.0", "invariant": "2.2.4" @@ -17993,8 +17977,7 @@ }, "node_modules/react-native-webview/node_modules/escape-string-regexp": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", "engines": { "node": ">=8" } diff --git a/expo/package.json b/expo/package.json index b3a8c91e..1dccca0d 100644 --- a/expo/package.json +++ b/expo/package.json @@ -60,7 +60,8 @@ "react-native-url-polyfill": "^2.0.0", "react-native-web": "~0.19.6", "react-native-webview": "13.2.2", - "socket.io-client": "^4.7.4" + "socket.io-client": "^4.7.4", + "@react-native-community/netinfo": "9.3.10" }, "devDependencies": { "@babel/core": "^7.20.0", From 4f91e1fb4c39f9c7eedc3085fa0ee73335a4b627 Mon Sep 17 00:00:00 2001 From: MAXOUXAX <24844231+MAXOUXAX@users.noreply.github.com> Date: Mon, 4 Mar 2024 17:48:18 +0100 Subject: [PATCH 20/24] fix: instead of adding the music that just started, we add the next music --- backend/src/RoomStorage.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/RoomStorage.ts b/backend/src/RoomStorage.ts index d9673be8..9e689ab9 100644 --- a/backend/src/RoomStorage.ts +++ b/backend/src/RoomStorage.ts @@ -102,7 +102,8 @@ export default class RoomStorage { let nextTrack = room.getQueue().at(0); if (nextTrack?.url === newPlaybackState.url) { - nextTrack = room.shiftQueue(); + room.shiftQueue(); + nextTrack = room.getQueue().at(0); } if (!nextTrack) return console.log("No more tracks in the queue"); From f879674a856f9612cb6b035470123ab6aa810bd5 Mon Sep 17 00:00:00 2001 From: MAXOUXAX <24844231+MAXOUXAX@users.noreply.github.com> Date: Tue, 19 Mar 2024 16:31:05 +0100 Subject: [PATCH 21/24] fix: wrong url used when immediately playing track when the queue is empty and no track is playing --- backend/src/socketio/Room.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/backend/src/socketio/Room.ts b/backend/src/socketio/Room.ts index 14eca696..77e35e85 100644 --- a/backend/src/socketio/Room.ts +++ b/backend/src/socketio/Room.ts @@ -90,20 +90,22 @@ export default class Room { async add(rawUrl: string) { if (!this.remote) return; - // If the queue is currently empty and no track is playing, we can play the track immediately - const playbackState = this.remote.getPlaybackState(); - if (this.queue.length === 0 && playbackState === null) { - const response = await this.remote.playTrack(rawUrl); - if (!response.error) await this.updatePlaybackState(); - return; - } - const trackMetadata = this.trackFactory.fromUrl(rawUrl); if (trackMetadata === null) return; const track = await trackMetadata.toJSON(); if (track === null) return; + // If the queue is currently empty and no track is playing, we can play the track immediately + const { data: playbackState } = await this.remote.getPlaybackState(); + console.log(playbackState, this.queue.length); + + if (this.queue.length === 0 && playbackState === null) { + const response = await this.remote.playTrack(track.url); + if (!response.error) await this.updatePlaybackState(); + return; + } + if (this.queue.map((value) => value.url).includes(track.url)) return; this.queue.push(track); From 9cc1772e7a42ed25e376a85bfdb62c907f6e126f Mon Sep 17 00:00:00 2001 From: MAXOUXAX <24844231+MAXOUXAX@users.noreply.github.com> Date: Thu, 21 Mar 2024 13:17:39 +0100 Subject: [PATCH 22/24] chore(eslint): fixed eslint and prettier conflicts --- .vscode/settings.json | 4 ++-- expo/.eslintrc.json | 8 ++------ expo/package-lock.json | 18 ++++++++++++++++-- expo/package.json | 5 +++-- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 9bffeda4..449ff8ed 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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" } diff --git a/expo/.eslintrc.json b/expo/.eslintrc.json index ecc7350c..d3003372 100644 --- a/expo/.eslintrc.json +++ b/expo/.eslintrc.json @@ -5,7 +5,7 @@ "es2021": true, "node": true }, - "extends": ["universe/native"], + "extends": ["universe/native", "prettier"], "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaVersion": "latest", @@ -13,10 +13,6 @@ }, "plugins": ["@typescript-eslint"], "rules": { - "indent": ["error", 2, { "SwitchCase": 1 }], - "linebreak-style": ["warn", "unix"], - "quotes": ["error", "double"], - "semi": ["error", "always"] + "quotes": ["error", "double"] } } - diff --git a/expo/package-lock.json b/expo/package-lock.json index 78345761..a713c182 100644 --- a/expo/package-lock.json +++ b/expo/package-lock.json @@ -67,6 +67,7 @@ "@typescript-eslint/parser": "^6.19.0", "axios": "^1.6.5", "eslint": "^8.56.0", + "eslint-config-prettier": "^9.1.0", "eslint-config-standard-with-typescript": "^43.0.0", "eslint-config-universe": "^12.0.0", "eslint-plugin-import": "^2.29.1", @@ -9465,9 +9466,10 @@ } }, "node_modules/eslint-config-prettier": { - "version": "8.10.0", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", + "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, - "license": "MIT", "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -9544,6 +9546,18 @@ } } }, + "node_modules/eslint-config-universe/node_modules/eslint-config-prettier": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.0.tgz", + "integrity": "sha512-SM8AMJdeQqRYT9O9zguiruQZaN7+z+E4eAP9oiLNGKMtomwaB1E9dcgUD6ZAn/eQAb52USbvezbiljfZUhbJcg==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-import-resolver-node": { "version": "0.3.9", "dev": true, diff --git a/expo/package.json b/expo/package.json index 1dccca0d..622795e1 100644 --- a/expo/package.json +++ b/expo/package.json @@ -18,6 +18,7 @@ "@expo/vector-icons": "^13.0.0", "@react-native-async-storage/async-storage": "1.18.2", "@react-native-community/checkbox": "^0.5.16", + "@react-native-community/netinfo": "9.3.10", "@react-native-community/slider": "4.4.2", "@react-navigation/native": "^6.0.2", "@spotify/web-api-ts-sdk": "^1.1.2", @@ -60,8 +61,7 @@ "react-native-url-polyfill": "^2.0.0", "react-native-web": "~0.19.6", "react-native-webview": "13.2.2", - "socket.io-client": "^4.7.4", - "@react-native-community/netinfo": "9.3.10" + "socket.io-client": "^4.7.4" }, "devDependencies": { "@babel/core": "^7.20.0", @@ -73,6 +73,7 @@ "@typescript-eslint/parser": "^6.19.0", "axios": "^1.6.5", "eslint": "^8.56.0", + "eslint-config-prettier": "^9.1.0", "eslint-config-standard-with-typescript": "^43.0.0", "eslint-config-universe": "^12.0.0", "eslint-plugin-import": "^2.29.1", From 62ba018ca4b77ce0babc9086596180b77450597f Mon Sep 17 00:00:00 2001 From: MAXOUXAX <24844231+MAXOUXAX@users.noreply.github.com> Date: Thu, 21 Mar 2024 14:14:07 +0100 Subject: [PATCH 23/24] chore: removed console.log logs and used console.debug for important debug logs --- backend/src/RoomStorage.ts | 18 +++++++++--------- backend/src/socketio/Room.ts | 13 ++++++------- expo/app/(tabs)/rooms/[id]/_layout.tsx | 4 ++-- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/backend/src/RoomStorage.ts b/backend/src/RoomStorage.ts index 9e689ab9..9e493a6e 100644 --- a/backend/src/RoomStorage.ts +++ b/backend/src/RoomStorage.ts @@ -3,10 +3,10 @@ import Deezer from "./musicplatform/Deezer"; import MusicPlatform from "./musicplatform/MusicPlatform"; import SoundCloud from "./musicplatform/SoundCloud"; import Spotify from "./musicplatform/Spotify"; +import { QueueableRemote } from "./musicplatform/remotes/Remote"; import { adminSupabase, server } from "./server"; import Room from "./socketio/Room"; import { RoomWithForeignTable } from "./socketio/RoomDatabase"; -import { QueueableRemote } from "./musicplatform/remotes/Remote"; const STREAMING_SERVICES = { Spotify: "a2d17b25-d87e-42af-9e79-fd4df6b59222", @@ -63,7 +63,7 @@ export default class RoomStorage { const newPlaybackState = newPlaybackStateResponse.data; room.setPlaybackState(newPlaybackState); - if (!newPlaybackState) return console.log("No music is playing"); + if (!newPlaybackState) return console.debug("No music is playing"); const remainingTime = newPlaybackState.duration - newPlaybackState.currentTime; @@ -74,21 +74,21 @@ export default class RoomStorage { !hasTriggeredEndingSoonValue && !(remote instanceof QueueableRemote) ) { - console.log( + 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.log("The track is not playing anymore"); + return console.debug("The track is not playing anymore"); this.startedTimer.set(room.uuid, false); const nextTrack = room.shiftQueue(); - if (!nextTrack) return console.log("No more tracks in the queue"); + if (!nextTrack) return console.debug("No more tracks in the queue"); remote.playTrack(nextTrack.url); - console.log(`The player will now play ${nextTrack.url}`); + console.debug(`The player will now play ${nextTrack.url}`); }, remainingTime); } @@ -96,7 +96,7 @@ export default class RoomStorage { // If the track has changed, we add the next track to the queue of the player if (newPlaybackState.url != previousPlaybackState?.url) { - console.log(`The track of room ${room.uuid} has changed`); + console.debug(`The track of room ${room.uuid} has changed`); if (remote instanceof QueueableRemote) { let nextTrack = room.getQueue().at(0); @@ -105,10 +105,10 @@ export default class RoomStorage { room.shiftQueue(); nextTrack = room.getQueue().at(0); } - if (!nextTrack) return console.log("No more tracks in the queue"); + if (!nextTrack) return console.debug("No more tracks in the queue"); remote.addToQueue(nextTrack.url); - console.log(`Just added ${nextTrack.url} to the queue`); + console.debug(`Just added ${nextTrack.url} to the queue`); } } }); diff --git a/backend/src/socketio/Room.ts b/backend/src/socketio/Room.ts index 77e35e85..d82eef3e 100644 --- a/backend/src/socketio/Room.ts +++ b/backend/src/socketio/Room.ts @@ -1,15 +1,15 @@ import { JSONTrack, PlayingJSONTrack, RoomJSON } from "commons/backend-types"; +import { + ClientToServerEvents, + ServerToClientEvents, +} from "commons/socket.io-types"; import { Socket } from "socket.io"; import RoomStorage from "../RoomStorage"; import MusicPlatform from "../musicplatform/MusicPlatform"; import TrackFactory from "../musicplatform/TrackFactory"; -import { RoomWithConfigDatabase } from "./RoomDatabase"; -import { adminSupabase } from "../server"; import { QueueableRemote, Remote } from "../musicplatform/remotes/Remote"; -import { - ClientToServerEvents, - ServerToClientEvents, -} from "commons/socket.io-types"; +import { adminSupabase } from "../server"; +import { RoomWithConfigDatabase } from "./RoomDatabase"; export type TypedSocket = Socket; @@ -98,7 +98,6 @@ export default class Room { // If the queue is currently empty and no track is playing, we can play the track immediately const { data: playbackState } = await this.remote.getPlaybackState(); - console.log(playbackState, this.queue.length); if (this.queue.length === 0 && playbackState === null) { const response = await this.remote.playTrack(track.url); diff --git a/expo/app/(tabs)/rooms/[id]/_layout.tsx b/expo/app/(tabs)/rooms/[id]/_layout.tsx index 4d76e2be..3eb7fc94 100644 --- a/expo/app/(tabs)/rooms/[id]/_layout.tsx +++ b/expo/app/(tabs)/rooms/[id]/_layout.tsx @@ -27,11 +27,11 @@ const WebSocketProvider = ({ const socketInstance = SocketIo.getInstance().getSocket(url.pathname); setWebSocket(socketInstance); - console.log("Socket connecté"); + console.debug("Socket connecté"); return () => { socketInstance.disconnect(); - console.log("Socket déconnecté"); + console.debug("Socket déconnecté"); }; }, [roomId]); From ecbdc3bbbeb1888b45ead24f67a6bbe3f67bd995 Mon Sep 17 00:00:00 2001 From: MAXOUXAX <24844231+MAXOUXAX@users.noreply.github.com> Date: Thu, 21 Mar 2024 14:15:22 +0100 Subject: [PATCH 24/24] refactor: improved code readability and minor optimization --- backend/src/server.ts | 22 +++++++++++----------- backend/src/socketio/RoomIO.ts | 30 ++++++++++++++---------------- 2 files changed, 25 insertions(+), 27 deletions(-) diff --git a/backend/src/server.ts b/backend/src/server.ts index 29e7350a..cd3b9efc 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,11 +1,8 @@ import fastifyCookie, { FastifyCookieOptions } from "@fastify/cookie"; import fastifyCors from "@fastify/cors"; -import { createClient } from "@supabase/supabase-js"; -import AuthCallbackBindSpotifyGET from "./route/AuthCallbackBindSpotifyGET"; -import AuthCallbackSoundcloudGET from "./route/AuthCallbackSoundcloudGET"; -import BoundServicesGET from "./route/BoundServicesGET"; -import UnbindServicePOST from "./route/UnbindServicePOST"; import { SpotifyApi } from "@spotify/web-api-ts-sdk"; +import { createClient } from "@supabase/supabase-js"; +import { Database } from "commons/database-types"; import { ClientToServerEvents, ServerToClientEvents, @@ -15,15 +12,18 @@ import fastify from "fastify"; import fastifySocketIO from "fastify-socket.io"; import { Server } from "socket.io"; import authRoutes from "./authRoutes"; +import AuthCallbackBindSpotifyGET from "./route/AuthCallbackBindSpotifyGET"; +import AuthCallbackSoundcloudGET from "./route/AuthCallbackSoundcloudGET"; +import BoundServicesGET from "./route/BoundServicesGET"; +import RoomConfigurationUpdatePOST from "./route/RoomConfigurationUpdatePOST"; +import RoomEndGET from "./route/RoomEndGET"; import RoomGET from "./route/RoomGET"; import RoomIdGET from "./route/RoomIdGET"; +import RoomLeaveGET from "./route/RoomLeaveGET"; import RoomPOST from "./route/RoomPOST"; -import RoomEndGET from "./route/RoomEndGET"; import StreamingServicesGET from "./route/StreamingServicesGET"; -import RoomIO from "./socketio/RoomIO"; -import { Database } from "commons/database-types"; -import RoomLeaveGET from "./route/RoomLeaveGET"; -import RoomConfigurationUpdatePOST from "./route/RoomConfigurationUpdatePOST"; +import UnbindServicePOST from "./route/UnbindServicePOST"; +import onRoomWSConnection from "./socketio/RoomIO"; config({ path: ".env.local" }); @@ -178,7 +178,7 @@ server.post("/room/configuration/:id", RoomConfigurationUpdatePOST); // server.get("/track/spotify/:id", SpotifyGET); server.ready().then(() => { - server.io.of(/^\/room\/.*$/i).on("connection", RoomIO); + server.io.of(/^\/room\/.*$/i).on("connection", onRoomWSConnection); }); server.listen({ port: 3000, host: "0.0.0.0" }); diff --git a/backend/src/socketio/RoomIO.ts b/backend/src/socketio/RoomIO.ts index 510ce5f2..5332f039 100644 --- a/backend/src/socketio/RoomIO.ts +++ b/backend/src/socketio/RoomIO.ts @@ -1,13 +1,15 @@ +import { JSONTrack } from "commons/backend-types"; import { Response } from "commons/socket.io-types"; import RoomStorage from "../RoomStorage"; -import { JSONTrack } from "commons/backend-types"; import Room, { TypedSocket } from "./Room"; const roomStorage = RoomStorage.getRoomStorage(); -export default function RoomIO( - socket: TypedSocket /*, next: (err?: ExtendedError) => void*/ -) { +const sendQueue = (socket: TypedSocket, room: Room) => { + socket.emit("queue:update", Room.toJSON(room)); +}; + +export default function onRoomWSConnection(socket: TypedSocket) { const roomSocket = socket.nsp; /*regex uuid [0-9a-f]{8}-([0-9a-f]{4}){3}-[0-9a-f]{12}*/ const pattern = /^\/room\/(.*)$/; @@ -34,25 +36,25 @@ export default function RoomIO( const hostSocket = isHostSocket ? socket : null; const room = await roomStorage.roomFromUuid(activeRoomId, hostSocket); - // Fetch participant of room at every connection for now - room?.updateParticipant(); - if (room === null) { socket.disconnect(); return; } + // Fetch participant of room at every connection for now + room.updateParticipant(); + /** * TODO * * Instead of sending the whole state, we should only send the actions taken by other users * so that the client can update its state accordingly */ - roomSocket.emit("queue:update", Room.toJSON(room)); + sendQueue(socket, room); socket.on("queue:add", async (params: string) => { await room.add(params); - sendQueueUpdated(); + sendQueue(socket, room); }); socket.on("queue:add", async (rawUrl: string) => { @@ -67,12 +69,12 @@ export default function RoomIO( await room.removeWithIndex(index); } } - sendQueueUpdated(); + sendQueue(socket, room); }); socket.on("queue:removeLink", async (link) => { await room.removeWithLink(link); - sendQueueUpdated(); + sendQueue(socket, room); }); socket.on("queue:voteSkip", async (index, userId) => { @@ -80,7 +82,7 @@ export default function RoomIO( if (!addedVote) return; const skipped = await room.verifyVoteSkip(index); - if (skipped === "queueTrackSkiped") sendQueueUpdated(); + if (skipped === "queueTrackSkiped") sendQueue(socket, room); if (skipped === "actualTrackSkiped") { const remote = room.getRemote(); if (!remote) return; @@ -88,10 +90,6 @@ export default function RoomIO( } }); - const sendQueueUpdated = () => { - roomSocket.emit("queue:update", Room.toJSON(room)); - }; - socket.on("player:playTrack", async (trackId) => { const remote = room.getRemote(); if (remote === null) return;