diff --git a/server/server.ts b/server/server.ts index 181622135..227b18486 100644 --- a/server/server.ts +++ b/server/server.ts @@ -53,6 +53,6 @@ const io = new socketio.Server(server, { }); app.set('socketio', io); -io.on('connection', (socket) => sockets(socket)); +io.on('connection', (socket) => sockets(socket, io)); server.listen(9321, () => console.log(`Listening on port ${PORT}`)); diff --git a/server/sockets/index.ts b/server/sockets/index.ts index d7e93a574..c7f8f5e88 100644 --- a/server/sockets/index.ts +++ b/server/sockets/index.ts @@ -1,15 +1,51 @@ -import { Socket } from 'socket.io'; +import { Socket, Server } from 'socket.io'; -export const sockets = (socket: Socket) => { - socket.broadcast.emit('user:connected', { - userID: socket.id, - username: socket.handshake.query.username, +export const sockets = (socket: Socket, io: Server) => { + socket.on('join', function (data) { + socket.join(data.id); // We are using room of socket io }); - socket.on('disconnect', () => { - socket.broadcast.emit('user:disconnected', { - userID: socket.id, - username: socket.handshake.query.username, + socket.broadcast.emit('user:receive:connect', { + socketId: socket.id, + userId: socket.handshake.query.id, + userName: socket.handshake.query.username, + }); + + socket.on('disconnect', async () => { + socket.broadcast.emit('user:receive:disconnect', { + socketId: socket.id, + userId: socket.handshake.query.id, + userName: socket.handshake.query.username, + }); + }); + + socket.on('user:send:get_online', async (data) => { + const sockets = await io.fetchSockets(); + const onlineSockets = sockets?.map((s) => s.handshake.query.id) || []; + + io.sockets + .in(data?.userId) + .emit('user:receive:get_online', { online: onlineSockets }); + }); + + socket.on('user:send:change_song', async (data) => { + socket.broadcast.emit('user:receive:change_song', { + ...data, + user: { ...data.user, socketId: socket.id }, + }); + }); + + socket.on('user:send:status_idle', async (data) => { + socket.broadcast.emit('user:receive:status_idle', { + status: 'idle', + user: { ...data.user, socketId: socket.id }, + }); + }); + + socket.on('user:send:status_playing', async (data) => { + socket.broadcast.emit('user:receive:status_playing', { + status: 'playing', + user: { ...data.user, socketId: socket.id }, }); }); }; diff --git a/src/renderer/features/sidebar/components/sidebar.tsx b/src/renderer/features/sidebar/components/sidebar.tsx index 589ff9879..36d6dabad 100644 --- a/src/renderer/features/sidebar/components/sidebar.tsx +++ b/src/renderer/features/sidebar/components/sidebar.tsx @@ -14,11 +14,13 @@ import { RiMusicLine, RiPlayListLine, RiSearchLine, + RiUser3Line, RiUserVoiceLine, } from 'react-icons/ri'; import { useNavigate, Link } from 'react-router-dom'; import styled from 'styled-components'; import { Button, TextInput } from '@/renderer/components'; +import { UserActivity } from '@/renderer/features/users'; import { AppRoute } from '@/renderer/router/routes'; import { useAppStore, usePlayerStore } from '@/renderer/store'; import { fadeIn } from '@/renderer/styles'; @@ -119,8 +121,10 @@ export const Sidebar = () => { setSidebar({ expanded: e })} > @@ -161,7 +165,7 @@ export const Sidebar = () => { - + Collections @@ -170,12 +174,25 @@ export const Sidebar = () => { - + Playlists + + + + + User Activity + + + +
+ +
+
+
diff --git a/src/renderer/features/users/components/user-activity-item.tsx b/src/renderer/features/users/components/user-activity-item.tsx new file mode 100644 index 000000000..4283ce6a8 --- /dev/null +++ b/src/renderer/features/users/components/user-activity-item.tsx @@ -0,0 +1,178 @@ +import React from 'react'; +import { Avatar, Group, Indicator, Stack } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { motion } from 'framer-motion'; +import { IoIosPause } from 'react-icons/io'; +import { RiPlayFill, RiServerLine, RiUserLine } from 'react-icons/ri'; +import { generatePath } from 'react-router'; +import { Link } from 'react-router-dom'; +import styled from 'styled-components'; +import { User } from '@/renderer/api/types'; +import { Popover, Text } from '@/renderer/components'; +import { useServerMap } from '@/renderer/features/servers'; +import { AppRoute } from '@/renderer/router/routes'; +import { titleCase } from '../../../utils/title-case'; + +export type Activity = { + socketId?: string; + song?: { + album?: string; + albumArtists: { id: string; name: string }[]; + albumId?: string; + id: string; + name: string; + serverId: string; + }; + status?: 'playing' | 'idle' | 'offline'; +}; + +export type UserWithActivity = User & { + activity?: Activity; + avatarUrl?: string; +}; + +interface UserActivityItemProps { + user: UserWithActivity; +} + +const ActivityContainer = styled(motion.div)` + padding: 0.5rem; +`; + +const ItemGrid = styled.div` + display: grid; + grid-auto-columns: 1fr; + grid-template-areas: 'image info'; + grid-template-rows: 1fr; + grid-template-columns: 50px minmax(0, 1fr); +`; + +const ItemImageContainer = styled.div` + display: flex; + grid-area: image; + align-items: center; +`; + +const ItemInfoContainer = styled.div` + grid-area: info; +`; + +export const UserActivityItem = ({ user }: UserActivityItemProps) => { + const { data: serverMap } = useServerMap(); + const [opened, { close, open }] = useDisclosure(false); + + const displayedName = user?.displayName + ? `${user.displayName} (${user.username})` + : user.username; + const songName = user?.activity?.song?.name; + const songId = user?.activity?.song?.id; + const albumId = user?.activity?.song?.albumId; + const albumName = user?.activity?.song?.album; + const serverId = user?.activity?.song?.serverId; + const status = user?.activity?.status; + const albumArtists = user?.activity?.song?.albumArtists; + + console.log('serverMap', serverMap); + + console.log('serverId', serverId); + + return ( + + + + + + + + + + + + + {serverId && } + + + + {displayedName} + + {serverId && ( + + {serverMap?.data[serverId]?.name} ( + {titleCase(serverMap?.data[serverId]?.type || '')}) + + )} + + + + + + + + + {songId ? songName : 'Idle...'} + + + {albumArtists?.length ? ( + albumArtists.map((artist, index) => ( + + {index > 0 ? ', ' : null} + + {artist.name} + + + )) + ) : ( + + )} + + {albumId ? ( + + {albumName} + + ) : ( + + — + + )} + + + {status === 'playing' ? ( + + ) : ( + + )} + + + + + + ); +}; diff --git a/src/renderer/features/users/components/user-activity.tsx b/src/renderer/features/users/components/user-activity.tsx new file mode 100644 index 000000000..1eed24f80 --- /dev/null +++ b/src/renderer/features/users/components/user-activity.tsx @@ -0,0 +1,268 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { motion } from 'framer-motion'; +import merge from 'lodash/merge'; +import sortBy from 'lodash/sortBy'; +import styled from 'styled-components'; +import { socket } from '@/renderer/api'; +import { UserListResponse } from '@/renderer/api/users.api'; +import { + Activity, + UserActivityItem, + UserWithActivity, +} from '@/renderer/features/users/components/user-activity-item'; +import { useUserList } from '@/renderer/features/users/queries/get-user-list'; +import { useAuthStore, usePlayerStore } from '@/renderer/store'; +import { PlayerStatus } from '@/renderer/types'; + +const UserActivityContainer = styled(motion.div)` + min-height: 10rem; + overflow-x: hidden; +`; + +type UserConnectionEvent = { + online?: string[]; + socketId: string; + userId: string; + userName: string; +}; + +type SongChangeEvent = { + song: Activity['song']; + user: UserConnectionEvent; +}; + +type CheckOnlineEvent = { + online: string[]; +}; + +type PlayStatusChangeEvent = { + status: Activity['status']; + user: UserConnectionEvent; +}; + +const sortByName = (users: UserWithActivity[]) => { + return sortBy(users, [ + (user) => user.displayName?.toLowerCase(), + (user) => user.username.toLowerCase(), + ]); +}; + +export const UserActivity = () => { + const currentSong = usePlayerStore((state) => state.current.song); + const currentUser = useAuthStore((state) => state.permissions); + const playStatus = usePlayerStore((state) => state.current.status); + const [activityList, setActivityList] = useState([]); + + const userDetails = useMemo( + () => ({ userId: currentUser?.id, userName: currentUser?.username }), + [currentUser?.id, currentUser?.username] + ); + + useUserList({ + onSuccess: async (data: UserListResponse) => { + const userList = data.data.filter((user) => user.id !== currentUser?.id); + setActivityList((prev) => { + const newList = userList.map((user) => { + const existingUser = prev.find((u) => u.id === user.id); + return merge({}, existingUser, user); + }); + return sortByName(newList); + }); + + if (userDetails) { + socket.emit('user:send:get_online', userDetails); + } + }, + staleTime: 0, + }); + + const handleGetOnlineUsers = useCallback((data: CheckOnlineEvent) => { + setActivityList((prev) => { + const updatedUsers = prev.map((user) => { + if (data.online.includes(user.id)) { + return { + ...user, + activity: { + ...user.activity, + status: 'idle' as Activity['status'], + }, + }; + } + return { + ...user, + activity: { + ...user.activity, + status: 'offline' as Activity['status'], + }, + }; + }); + return sortByName(updatedUsers); + }); + }, []); + + const handleUserConnect = useCallback((data: UserConnectionEvent) => { + setActivityList((prev) => { + const user = prev.find((user) => user.id === data.userId); + if (!user) return prev; + + return sortByName([ + ...prev.filter((user) => user.id !== data.userId), + { + ...user, + activity: { + ...user?.activity, + socketId: data.socketId, + status: 'idle', + }, + }, + ]); + }); + }, []); + + const handleUserDisconnect = useCallback((data: UserConnectionEvent) => { + setActivityList((prev) => { + const user = prev.find((user) => user.id === data.userId); + if (!user) return prev; + + return sortByName([ + ...prev.filter((user) => user.id !== data.userId), + { + ...user, + activity: { + ...user?.activity, + socketId: undefined, + song: undefined, + status: 'offline', + }, + }, + ]); + }); + }, []); + + const handleUserSongChange = useCallback((data: SongChangeEvent) => { + setActivityList((prev) => { + const user = prev.find((user) => user.id === data.user.userId); + if (!user) return prev; + + const shouldUpdateStatus = + !user?.activity?.status || user?.activity?.status === 'offline'; + + return sortByName([ + ...prev.filter((user) => user.id !== data.user.userId), + { + ...user, + activity: { + ...user.activity, + socketId: data.user.socketId, + song: data.song, + status: shouldUpdateStatus ? 'playing' : user?.activity?.status, + }, + }, + ]); + }); + }, []); + + const handleUserStatusChange = useCallback((data: PlayStatusChangeEvent) => { + console.log('data', data); + setActivityList((prev) => { + const user = prev.find((user) => user.id === data.user.userId); + if (!user) return prev; + + console.log('data.status', data.status); + + return sortByName([ + ...prev.filter((user) => user.id !== data.user.userId), + { + ...user, + activity: { + ...user.activity, + socketId: data.user.socketId, + status: data.status, + }, + }, + ]); + }); + }, []); + + useEffect(() => { + if (currentSong) { + const currentSongDetails: Activity['song'] = { + album: currentSong?.album?.name, + albumArtists: currentSong?.album?.albumArtists.map((artist) => ({ + id: artist.id, + name: artist.name, + })), + albumId: currentSong?.album?.id, + id: currentSong?.id, + name: currentSong?.name, + serverId: currentSong?.serverId, + }; + + socket.emit('user:send:change_song', { + song: currentSongDetails, + user: userDetails, + }); + } + }, [currentSong, playStatus, userDetails]); + + useEffect(() => { + if (playStatus === PlayerStatus.PAUSED) { + socket.emit('user:send:status_idle', { user: userDetails }); + } else { + socket.emit('user:send:status_playing', { user: userDetails }); + } + }, [playStatus, userDetails]); + + useEffect(() => { + socket.on('user:receive:connect', (data: UserConnectionEvent) => { + handleUserConnect(data); + }); + + socket.on('user:receive:disconnect', (data: UserConnectionEvent) => { + handleUserDisconnect(data); + }); + + socket.on('user:receive:change_song', (data: SongChangeEvent) => { + handleUserSongChange(data); + }); + + socket.on('user:receive:status_idle', (data: PlayStatusChangeEvent) => { + handleUserStatusChange(data); + }); + + socket.on('user:receive:status_playing', (data: PlayStatusChangeEvent) => { + handleUserStatusChange(data); + }); + + socket.on('user:receive:get_online', (data: CheckOnlineEvent) => { + handleGetOnlineUsers(data); + }); + + return () => { + socket.off('user:receive:connect'); + socket.off('user:recieve:disconnect'); + socket.off('user:receive:change_song'); + socket.off('user:receive:status_idle'); + socket.off('user:receive:status_playing'); + socket.off('user:receive:get_online'); + }; + }, [ + handleGetOnlineUsers, + handleUserConnect, + handleUserDisconnect, + handleUserSongChange, + handleUserStatusChange, + ]); + + return ( + + {activityList + .filter( + (user) => user.activity?.status && user.activity?.status !== 'offline' + ) + .map((user) => ( + + ))} + + ); +}; diff --git a/src/renderer/features/users/index.ts b/src/renderer/features/users/index.ts index d07e03bc0..601b99a93 100644 --- a/src/renderer/features/users/index.ts +++ b/src/renderer/features/users/index.ts @@ -3,5 +3,7 @@ export * from './mutations/delete-user'; export * from './mutations/update-user'; export * from './components/add-user-form'; export * from './components/user-list'; +export * from './components/user-activity'; +export * from './components/user-activity-item'; export * from './queries/get-user-detail'; export * from './queries/get-user-list';