diff --git a/frontend/src/pages/chat/css/_callWindow.scss b/frontend/src/pages/chat/css/_callWindow.scss index ccca703..4614ebd 100644 --- a/frontend/src/pages/chat/css/_callWindow.scss +++ b/frontend/src/pages/chat/css/_callWindow.scss @@ -1,44 +1,158 @@ @use "../../../css/material" as *; .call-window { + $transition: cubic-bezier(0.4, 0, 0.2, 1); + position: fixed; z-index: 1000; width: 300px; - background-color: $color-dark-surface; - border: 1px solid $color-dark-outline; + background-color: rgba($color-dark-surface, 0.85); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid rgba($color-dark-outline, 0.3); border-radius: 12px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); cursor: grab; + transition: opacity 0.3s $transition, transform 0.3s $transition; + transform-origin: center; + user-select: none; + + &.visible { + opacity: 1; + transform: scale(1); + } + + &.hidden { + opacity: 0; + transform: scale(0.8) translateY(-20px); + pointer-events: none; + } &.dragging { cursor: grabbing; } + + // Dynamic gradients based on call state + &.gradient-calling { + border-color: rgba(255, 193, 7, 0.4); + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, + rgba(255, 193, 7, 0.15) 0%, + rgba(255, 152, 0, 0.15) 50%, + rgba(255, 193, 7, 0.15) 100%); + border-radius: 12px; + animation: pulse-gradient 2s ease-in-out infinite; + } + } + + &.gradient-connecting { + border-color: rgba(33, 150, 243, 0.4); + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, + rgba(33, 150, 243, 0.15) 0%, + rgba(63, 81, 181, 0.15) 50%, + rgba(33, 150, 243, 0.15) 100%); + border-radius: 12px; + animation: connecting-gradient 1.5s ease-in-out infinite; + } + } + + &.gradient-active { + border-color: rgba(76, 175, 80, 0.4); + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, + rgba(76, 175, 80, 0.15) 0%, + rgba(56, 142, 60, 0.15) 50%, + rgba(76, 175, 80, 0.15) 100%); + border-radius: 12px; + animation: active-gradient 3s ease-in-out infinite; + } + } + + &.gradient-default { + border-color: rgba(158, 158, 158, 0.3); + } } .call-header { padding: 16px; border-bottom: 1px solid $color-dark-outline-variant; - cursor: grab; + position: relative; + z-index: 1; - .user-info { + .window-controls { + position: absolute; + top: 8px; + right: 8px; + z-index: 2; + + .minimize-btn { + color: $color-dark-on-surface-variant; + transition: color 0.2s ease; + + &:hover { + color: $color-dark-on-surface; + } + } + } + + .user-info-centered { display: flex; + flex-direction: column; align-items: center; gap: 12px; + text-align: center; .avatar { - width: 48px; - height: 48px; + width: 64px; + height: 64px; border-radius: 50%; object-fit: cover; + box-shadow: 0 0 20px rgba(33, 150, 243, 0.3); + transition: box-shadow 0.3s ease; + position: relative; + z-index: 1; + + // Colored shadow based on call state + .gradient-calling & { + box-shadow: 0 0 20px rgba(255, 193, 7, 0.4); + } + + .gradient-connecting & { + box-shadow: 0 0 20px rgba(33, 150, 243, 0.4); + } + + .gradient-active & { + box-shadow: 0 0 20px rgba(76, 175, 80, 0.4); + } } .user-details { - flex: 1; - .username { margin: 0; - font-size: 16px; - font-weight: 500; + font-size: 18px; + font-weight: 600; color: $color-dark-on-surface; } @@ -46,6 +160,7 @@ margin: 4px 0 0 0; font-size: 14px; color: $color-dark-on-surface-variant; + font-weight: 500; } } } @@ -58,6 +173,132 @@ gap: 12px; } +// Minimized call bar +.minimized-call-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 60px; + background: rgba($color-dark-surface, 0.85); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-top: 1px solid rgba($color-dark-outline, 0.3); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + z-index: 999; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, + transparent 0%, + rgba(255, 255, 255, 0.1) 50%, + transparent 100%); + animation: shimmer 2s ease-in-out infinite; + } + + .call-info { + display: flex; + align-items: center; + gap: 12px; + + .avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + } + + .user-details { + display: flex; + flex-direction: column; + gap: 2px; + + .username { + font-size: 14px; + font-weight: 600; + color: $color-dark-on-surface; + } + + .status { + font-size: 12px; + color: $color-dark-on-surface-variant; + } + } + } + + .call-actions { + display: flex; + gap: 8px; + } + + // Dynamic gradients for minimized bar + &.gradient-calling { + border-color: rgba(255, 193, 7, 0.4); + + &::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, + transparent 0%, + rgba(255, 193, 7, 0.1) 50%, + transparent 100%); + animation: shimmer-calling 2s ease-in-out infinite; + } + } + + &.gradient-connecting { + border-color: rgba(33, 150, 243, 0.4); + + &::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, + transparent 0%, + rgba(33, 150, 243, 0.1) 50%, + transparent 100%); + animation: shimmer-connecting 1.5s ease-in-out infinite; + } + } + + &.gradient-active { + border-color: rgba(76, 175, 80, 0.4); + + &::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, + transparent 0%, + rgba(76, 175, 80, 0.1) 50%, + transparent 100%); + animation: shimmer-active 3s ease-in-out infinite; + } + } +} + .remote-audio { position: fixed; bottom: 0px; @@ -66,4 +307,72 @@ height: 0; opacity: 0; visibility: hidden; +} + +// Animations +@keyframes pulse-gradient { + 0%, 100% { + opacity: 0.3; + transform: scale(1); + } + 50% { + opacity: 0.6; + transform: scale(1.02); + } +} + +@keyframes connecting-gradient { + 0%, 100% { + opacity: 0.2; + transform: translateX(0); + } + 50% { + opacity: 0.4; + transform: translateX(10px); + } +} + +@keyframes active-gradient { + 0%, 100% { + opacity: 0.15; + } + 50% { + opacity: 0.25; + } +} + +@keyframes shimmer { + 0% { + left: -100%; + } + 100% { + left: 100%; + } +} + +@keyframes shimmer-calling { + 0% { + left: -100%; + } + 100% { + left: 100%; + } +} + +@keyframes shimmer-connecting { + 0% { + left: -100%; + } + 100% { + left: 100%; + } +} + +@keyframes shimmer-active { + 0% { + left: -100%; + } + 100% { + left: 100%; + } } \ No newline at end of file diff --git a/frontend/src/pages/chat/state.ts b/frontend/src/pages/chat/state.ts index 1600abb..099e1c4 100644 --- a/frontend/src/pages/chat/state.ts +++ b/frontend/src/pages/chat/state.ts @@ -28,6 +28,7 @@ interface CallState { remoteUserId: number | null; remoteUsername: string | null; isInitiator: boolean; + isMinimized: boolean; } interface ChatState { @@ -72,6 +73,7 @@ interface AppState { endCall: () => void; setCallStatus: (status: CallStatus) => void; toggleMute: () => void; + toggleCallMinimize: () => void; receiveCall: (userId: number, username: string) => void; // User state @@ -107,7 +109,8 @@ export const useAppState = create((set, get) => ({ isMuted: false, remoteUserId: null, remoteUsername: null, - isInitiator: false + isInitiator: false, + isMinimized: false } }, addMessage: (message: Message) => set((state) => { @@ -400,7 +403,8 @@ export const useAppState = create((set, get) => ({ isMuted: false, remoteUserId: userId, remoteUsername: username, - isInitiator: true + isInitiator: true, + isMinimized: false } } })), @@ -415,7 +419,8 @@ export const useAppState = create((set, get) => ({ isMuted: false, remoteUserId: null, remoteUsername: null, - isInitiator: false + isInitiator: false, + isMinimized: false } } })), @@ -440,6 +445,16 @@ export const useAppState = create((set, get) => ({ } } })), + + toggleCallMinimize: () => set((state) => ({ + chat: { + ...state.chat, + call: { + ...state.chat.call, + isMinimized: !state.chat.call.isMinimized + } + } + })), receiveCall: (userId: number, username: string) => set((state) => ({ chat: { @@ -451,7 +466,8 @@ export const useAppState = create((set, get) => ({ isMuted: false, remoteUserId: userId, remoteUsername: username, - isInitiator: false + isInitiator: false, + isMinimized: false } } })) diff --git a/frontend/src/pages/chat/ui/ChatPage.tsx b/frontend/src/pages/chat/ui/ChatPage.tsx index 844ccfb..1183ab9 100644 --- a/frontend/src/pages/chat/ui/ChatPage.tsx +++ b/frontend/src/pages/chat/ui/ChatPage.tsx @@ -2,7 +2,7 @@ import { LeftPanel } from "./left/LeftPanel"; import { RightPanel } from "./right/RightPanel"; import "@/pages/chat/css/chat.scss"; import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; -import { CallWindow } from "./right/CallWindow"; +import { CallWindow } from "./right/calls/CallWindow"; export default function ChatPage() { const { navigate: navigateDownloadApp } = useDownloadAppScreen(); diff --git a/frontend/src/pages/chat/ui/left/ChatHeader.tsx b/frontend/src/pages/chat/ui/left/ChatHeader.tsx index a76bc15..b068269 100644 --- a/frontend/src/pages/chat/ui/left/ChatHeader.tsx +++ b/frontend/src/pages/chat/ui/left/ChatHeader.tsx @@ -3,35 +3,29 @@ import useProfile from "@/pages/chat/hooks/useProfile"; import defaultAvatar from "@/images/default-avatar.png"; import { useState } from "react"; import { ProfileDialog } from "./profile/ProfileDialog"; +import { MinimizedCallBar } from "@/pages/chat/ui/right/calls/MinimizedCallBar"; export function ChatHeader() { const { profileData } = useProfile(); const [isProfileOpen, setIsProfileOpen] = useState(false); - const handleProfileClick = () => { - setIsProfileOpen(true); - }; - - const profilePictureUrl = profileData?.profile_picture || defaultAvatar; + const [profilePictureUrl, setProfilePictureUrl] = useState(profileData?.profile_picture || defaultAvatar); return ( <>
{PRODUCT_NAME}
+ ); diff --git a/frontend/src/pages/chat/ui/right/CallWindow.tsx b/frontend/src/pages/chat/ui/right/calls/CallWindow.tsx similarity index 51% rename from frontend/src/pages/chat/ui/right/CallWindow.tsx rename to frontend/src/pages/chat/ui/right/calls/CallWindow.tsx index 44747ac..41c059d 100644 --- a/frontend/src/pages/chat/ui/right/CallWindow.tsx +++ b/frontend/src/pages/chat/ui/right/calls/CallWindow.tsx @@ -4,13 +4,26 @@ import useAudioCall from "@/pages/chat/hooks/useAudioCall"; import defaultAvatar from "@/images/default-avatar.png"; export function CallWindow() { - const { chat, toggleMute } = useAppState(); + const { chat, toggleMute, toggleCallMinimize } = useAppState(); const { call } = chat; const { acceptCall, rejectCall, remoteAudioRef, endCall } = useAudioCall(); const [position, setPosition] = useState({ x: 100, y: 100 }); const [isDragging, setIsDragging] = useState(false); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [callDuration, setCallDuration] = useState(0); + const [isVisible, setIsVisible] = useState(false); + const [shouldRender, setShouldRender] = useState(false); + const [callData, setCallData] = useState<{ + remoteUsername: string | null; + status: "calling" | "connecting" | "active" | "ended"; + isInitiator: boolean; + isMuted: boolean; + } | null>(null); + + const status = callData?.status || call.status; + const remoteUsername = callData?.remoteUsername || call.remoteUsername; + const isInitiator = callData?.isInitiator || call.isInitiator; + const isMuted = callData?.isMuted || call.isMuted; useEffect(() => { let interval: NodeJS.Timeout; @@ -28,6 +41,62 @@ export function CallWindow() { }; }, [call.status, call.startTime]); + // Preserve call data during exit animation + useEffect(() => { + if (call.isActive) { + setCallData({ + remoteUsername: call.remoteUsername, + status: call.status, + isInitiator: call.isInitiator, + isMuted: call.isMuted + }); + } + }, [call.isActive, call.remoteUsername, call.status, call.isInitiator, call.isMuted]); + + // Handle visibility animation with entrance and exit delays + useEffect(() => { + if (call.isActive) { + setShouldRender(true); + if (!call.isMinimized) { + // Call is active and not minimized - show window with entrance animation + if (!isVisible) { + // Only animate in if not already visible (prevents animation on rapid calls) + requestAnimationFrame(() => { + setIsVisible(true); + }); + } + } else { + // Call is active but minimized - hide window but keep rendered + setIsVisible(false); + } + } else { + if (shouldRender) { + // Call ended - start exit animation + setIsVisible(false); + // After animation completes, stop rendering + const timer = setTimeout(() => { + setShouldRender(false); + setCallData(null); + }, 300); // Match the CSS transition duration + return () => clearTimeout(timer); + } else { + // Call not active and not rendered - ensure clean state + setShouldRender(false); + setIsVisible(false); + setCallData(null); + } + } + }, [call.isActive, call.isMinimized, shouldRender, isVisible]); + + // Cleanup effect to reset state when component unmounts + useEffect(() => { + return () => { + setIsVisible(false); + setShouldRender(false); + setCallData(null); + }; + }, []); + function formatDuration(seconds: number) { const mins = Math.floor(seconds / 60); const secs = seconds % 60; @@ -35,7 +104,7 @@ export function CallWindow() { } function getStatusText() { - switch (call.status) { + switch (status) { case "calling": return "Calling..."; case "connecting": @@ -47,6 +116,19 @@ export function CallWindow() { } } + function getGradientClass() { + switch (status) { + case "calling": + return "gradient-calling"; + case "connecting": + return "gradient-connecting"; + case "active": + return "gradient-active"; + default: + return "gradient-default"; + } + } + return ( <> @@ -57,9 +139,9 @@ export function CallWindow() { playsInline controls /> - {call.isActive && ( + {shouldRender && (
setIsDragging(false)} >
-
+
+ +
+ +
Avatar

- {call.remoteUsername} + {remoteUsername}

{getStatusText()} @@ -101,14 +191,14 @@ export function CallWindow() {

- {call.status === "calling" && !call.isInitiator ? ( + {status === "calling" && !isInitiator ? ( <> ) : ( <> - + )} diff --git a/frontend/src/pages/chat/ui/right/calls/MinimizedCallBar.tsx b/frontend/src/pages/chat/ui/right/calls/MinimizedCallBar.tsx new file mode 100644 index 0000000..e687372 --- /dev/null +++ b/frontend/src/pages/chat/ui/right/calls/MinimizedCallBar.tsx @@ -0,0 +1,62 @@ +import { useAppState } from "@/pages/chat/state"; +import useAudioCall from "@/pages/chat/hooks/useAudioCall"; +import defaultAvatar from "@/images/default-avatar.png"; + +export function MinimizedCallBar() { + const { chat, toggleCallMinimize } = useAppState(); + const { call } = chat; + const { endCall, toggleMute } = useAudioCall(); + + function getGradientClass() { + switch (call.status) { + case "calling": + return "gradient-calling"; + case "connecting": + return "gradient-connecting"; + case "active": + return "gradient-active"; + default: + return "gradient-default"; + } + } + + function getStatusText() { + switch (call.status) { + case "calling": + return "Calling..."; + case "connecting": + return "Connecting..."; + case "active": + return "Active"; + default: + return ""; + } + } + + if (!call.isActive || !call.isMinimized) { + return null; + } + + return ( +
+
+ Avatar +
+ {call.remoteUsername} + {getStatusText()} +
+
+ +
e.stopPropagation()}> + {call.status === "calling" && !call.isInitiator ? ( + + ) : ( + <> + + + + )} +
+
+ ); +}