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';