From c010cf153c9c63cbcc463045283b8e42c8333816 Mon Sep 17 00:00:00 2001 From: yeonny0723 Date: Tue, 10 Mar 2026 22:06:15 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20preference=EC=97=90=20volume/muted?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20ui-state=EC=97=90=20cinema?= =?UTF-8?q?=20=EB=B7=B0=20=EC=83=81=ED=83=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.development | 3 --- src/entities/preference/model/constants.ts | 4 +--- src/entities/preference/model/user-preference.model.ts | 4 ++++ src/entities/preference/model/user-preference.store.ts | 8 +++++++- src/entities/ui-state/model/ui-state.model.tsx | 8 ++++++++ src/entities/ui-state/model/ui-state.store.tsx | 8 ++++++++ 6 files changed, 28 insertions(+), 7 deletions(-) delete mode 100644 .env.development diff --git a/.env.development b/.env.development deleted file mode 100644 index 5984ace4..00000000 --- a/.env.development +++ /dev/null @@ -1,3 +0,0 @@ -NEXT_PUBLIC_API_HOST_NAME="http://localhost:8080/api/" -NEXT_PUBLIC_API_WS_HOST_NAME="ws://localhost:8080/ws" -NEXT_PUBLIC_USE_MOCK="false" diff --git a/src/entities/preference/model/constants.ts b/src/entities/preference/model/constants.ts index 4ab03c70..5028161c 100644 --- a/src/entities/preference/model/constants.ts +++ b/src/entities/preference/model/constants.ts @@ -1,3 +1 @@ -export const USER_PREFERENCES_KEY = { - HIDDEN_DJING_GUIDE: 'HIDDEN_DJING_GUIDE', -} as const; +export const USER_PREFERENCES_KEY = 'user-preferences'; diff --git a/src/entities/preference/model/user-preference.model.ts b/src/entities/preference/model/user-preference.model.ts index 2a0aed5b..9bd72bea 100644 --- a/src/entities/preference/model/user-preference.model.ts +++ b/src/entities/preference/model/user-preference.model.ts @@ -2,6 +2,10 @@ export namespace Preference { export interface Model { djingGuideHidden: boolean; setDjingGuideHidden: (hidden: boolean) => void; + volume: number; + muted: boolean; + setVolume: (v: number) => void; + setMuted: (m: boolean) => void; reset: () => void; } } diff --git a/src/entities/preference/model/user-preference.store.ts b/src/entities/preference/model/user-preference.store.ts index bc0fa37d..ce037fb7 100644 --- a/src/entities/preference/model/user-preference.store.ts +++ b/src/entities/preference/model/user-preference.store.ts @@ -8,12 +8,18 @@ export const useUserPreferenceStore = create()( (set, _, api) => ({ djingGuideHidden: false, setDjingGuideHidden: (hidden) => set({ djingGuideHidden: hidden }), + volume: 0.5, + muted: false, + setVolume: (v) => set({ volume: v }), + setMuted: (m) => set({ muted: m }), reset: () => set(api.getInitialState(), true), }), { - name: USER_PREFERENCES_KEY.HIDDEN_DJING_GUIDE, + name: USER_PREFERENCES_KEY, partialize: (state) => ({ djingGuideHidden: state.djingGuideHidden, + volume: state.volume, + muted: state.muted, }), } ) diff --git a/src/entities/ui-state/model/ui-state.model.tsx b/src/entities/ui-state/model/ui-state.model.tsx index 14d8e889..d76f5901 100644 --- a/src/entities/ui-state/model/ui-state.model.tsx +++ b/src/entities/ui-state/model/ui-state.model.tsx @@ -11,4 +11,12 @@ type PlaylistDrawerState = { export type Model = { playlistDrawer: PlaylistDrawerState; setPlaylistDrawer: (v: Next) => void; + cinemaView: boolean; + setCinemaView: (v: boolean) => void; + cinemaChatOpen: boolean; + setCinemaChatOpen: (v: boolean) => void; + pendingFullscreen: boolean; + setPendingFullscreen: (v: boolean) => void; + cinemaSidePanel: 'none' | 'detail' | 'playlist'; + setCinemaSidePanel: (v: 'none' | 'detail' | 'playlist') => void; }; diff --git a/src/entities/ui-state/model/ui-state.store.tsx b/src/entities/ui-state/model/ui-state.store.tsx index 43daf4de..04376455 100644 --- a/src/entities/ui-state/model/ui-state.store.tsx +++ b/src/entities/ui-state/model/ui-state.store.tsx @@ -42,5 +42,13 @@ export const createUIStateStore = () => { }; }); }, + cinemaView: false, + setCinemaView: (v) => set({ cinemaView: v }), + cinemaChatOpen: false, + setCinemaChatOpen: (v) => set({ cinemaChatOpen: v }), + pendingFullscreen: false, + setPendingFullscreen: (v) => set({ pendingFullscreen: v }), + cinemaSidePanel: 'none', + setCinemaSidePanel: (v) => set({ cinemaSidePanel: v }), })); }; From bd9b735c3e57cddb302325b3c7aba02e8d9f5cd6 Mon Sep 17 00:00:00 2001 From: yeonny0723 Date: Tue, 10 Mar 2026 22:06:24 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EB=B3=BC=EB=A5=A8=20=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4,=20=EB=B9=84=EB=94=94=EC=98=A4=20=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=8F=20hover=20popup=20=ED=9B=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/lib/hooks/use-hover-popup.hook.ts | 27 +++++++ .../ui/parts/video-controls.component.tsx | 40 ++++++++++ .../ui/parts/volume-control.component.tsx | 76 +++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 src/shared/lib/hooks/use-hover-popup.hook.ts create mode 100644 src/widgets/partyroom-display-board/ui/parts/video-controls.component.tsx create mode 100644 src/widgets/partyroom-display-board/ui/parts/volume-control.component.tsx diff --git a/src/shared/lib/hooks/use-hover-popup.hook.ts b/src/shared/lib/hooks/use-hover-popup.hook.ts new file mode 100644 index 00000000..5aa6296e --- /dev/null +++ b/src/shared/lib/hooks/use-hover-popup.hook.ts @@ -0,0 +1,27 @@ +import { useRef, useState } from 'react'; + +const DEFAULT_HIDE_DELAY_MS = 120; + +/** + * 마우스 hover/leave 이벤트에 딜레이를 적용해 팝업 열림/닫힘 상태를 관리하는 훅. + * + * hover zone이 두 개 이상일 때 (trigger + popup) 사이의 미세한 gap에서 + * hover가 끊기지 않도록 짧은 딜레이로 타이머를 취소할 시간을 확보한다. + * + * @param hideDelay - mouseLeave 후 닫힘 지연 시간(ms). 기본값 120 + */ +export function useHoverPopup(hideDelay = DEFAULT_HIDE_DELAY_MS) { + const [open, setOpen] = useState(false); + const timerRef = useRef>(); + + const show = () => { + clearTimeout(timerRef.current); + setOpen(true); + }; + + const hide = () => { + timerRef.current = setTimeout(() => setOpen(false), hideDelay); + }; + + return { open, show, hide }; +} diff --git a/src/widgets/partyroom-display-board/ui/parts/video-controls.component.tsx b/src/widgets/partyroom-display-board/ui/parts/video-controls.component.tsx new file mode 100644 index 00000000..f212b43c --- /dev/null +++ b/src/widgets/partyroom-display-board/ui/parts/video-controls.component.tsx @@ -0,0 +1,40 @@ +'use client'; +import { useStores } from '@/shared/lib/store/stores.context'; +import { PFFullscreen, PFTheaterView } from '@/shared/ui/icons'; +import VolumeControl from './volume-control.component'; + +type Props = { + onTheater: () => void; + onFull: () => void; +}; + +export default function VideoControls({ onTheater, onFull }: Props) { + const { useUIState } = useStores(); + const cinemaView = useUIState((s) => s.cinemaView); + + return ( +
+ +
+ {!cinemaView && ( + + )} + +
+
+ ); +} diff --git a/src/widgets/partyroom-display-board/ui/parts/volume-control.component.tsx b/src/widgets/partyroom-display-board/ui/parts/volume-control.component.tsx new file mode 100644 index 00000000..4d79f28e --- /dev/null +++ b/src/widgets/partyroom-display-board/ui/parts/volume-control.component.tsx @@ -0,0 +1,76 @@ +'use client'; +import { useUserPreferenceStore } from '@/entities/preference'; +import { cn } from '@/shared/lib/functions/cn'; +import { useHoverPopup } from '@/shared/lib/hooks/use-hover-popup.hook'; +import { PFVolumeOff, PFVolumeOn } from '@/shared/ui/icons'; + +type Props = { + iconSize?: number; +}; + +export default function VolumeControl({ iconSize = 16 }: Props) { + const volume = useUserPreferenceStore((s) => s.volume); + const muted = useUserPreferenceStore((s) => s.muted); + const setVolume = useUserPreferenceStore((s) => s.setVolume); + const setMuted = useUserPreferenceStore((s) => s.setMuted); + + const { open, show, hide } = useHoverPopup(); + + const isMuted = muted || volume === 0; + + const handleVolumeChange = (e: React.ChangeEvent) => { + const v = parseFloat(e.target.value); + setVolume(v); + if (v > 0 && muted) setMuted(false); + if (v === 0) setMuted(true); + }; + + return ( +
+ {/* Vertical slider popup */} +
+ +
+ + {/* Volume icon — click to mute/unmute */} + +
+ ); +} From 44447be59735352a31c530cf8f1d2b4bc2f1c262 Mon Sep 17 00:00:00 2001 From: yeonny0723 Date: Tue, 10 Mar 2026 22:07:21 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20partyroom=20display=20board=20?= =?UTF-8?q?=EB=8C=80=ED=98=95=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[id]/_panels/chat-tab-panel.component.tsx | 43 ++++ .../_panels/cinema-detail-panel.component.tsx | 26 ++ .../cinema-playlist-panel.component.tsx | 142 +++++++++++ src/app/parties/(room)/[id]/page.tsx | 195 ++++++++++++--- .../ui/display-board.component.tsx | 21 +- .../ui/parts/cinema-footer.component.tsx | 124 ++++++++++ .../ui/parts/cinema-header.component.tsx | 20 ++ .../ui/parts/video.component.tsx | 227 +++++++++++++++--- 8 files changed, 735 insertions(+), 63 deletions(-) create mode 100644 src/app/parties/(room)/[id]/_panels/chat-tab-panel.component.tsx create mode 100644 src/app/parties/(room)/[id]/_panels/cinema-detail-panel.component.tsx create mode 100644 src/app/parties/(room)/[id]/_panels/cinema-playlist-panel.component.tsx create mode 100644 src/widgets/partyroom-display-board/ui/parts/cinema-footer.component.tsx create mode 100644 src/widgets/partyroom-display-board/ui/parts/cinema-header.component.tsx diff --git a/src/app/parties/(room)/[id]/_panels/chat-tab-panel.component.tsx b/src/app/parties/(room)/[id]/_panels/chat-tab-panel.component.tsx new file mode 100644 index 00000000..d7639cd9 --- /dev/null +++ b/src/app/parties/(room)/[id]/_panels/chat-tab-panel.component.tsx @@ -0,0 +1,43 @@ +'use client'; +import { cn } from '@/shared/lib/functions/cn'; +import { useI18n } from '@/shared/lib/localization/i18n.context'; +import { useStores } from '@/shared/lib/store/stores.context'; +import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@/shared/ui/components/tab'; +import { PFChatFilled, PFPersonOutline } from '@/shared/ui/icons'; +import { PartyroomChatPanel } from '@/widgets/partyroom-chat-panel'; +import { PartyroomCrewsPanel } from '@/widgets/partyroom-crews-panel'; + +type Props = { + className?: string; +}; + +export default function ChatTabPanel({ className }: Props) { + const t = useI18n(); + const { useCurrentPartyroom } = useStores(); + const crewsCount = useCurrentPartyroom((state) => state.crews.length); + + return ( + + + } + /> + } + /> + + + + + + + + + + + ); +} diff --git a/src/app/parties/(room)/[id]/_panels/cinema-detail-panel.component.tsx b/src/app/parties/(room)/[id]/_panels/cinema-detail-panel.component.tsx new file mode 100644 index 00000000..f2009e12 --- /dev/null +++ b/src/app/parties/(room)/[id]/_panels/cinema-detail-panel.component.tsx @@ -0,0 +1,26 @@ +'use client'; +import { Panel } from '@/widgets/partyroom-detail/lib/panel-controller.context'; +import { PanelController } from '@/widgets/partyroom-detail/lib/panel-controller.provider'; +import MainPanel from '@/widgets/partyroom-detail/ui/main-panel.component'; +import PanelHeader from '@/widgets/partyroom-detail/ui/panel-header.component'; +import PlaybackHistoryPanel from '@/widgets/partyroom-detail/ui/playback-history-panel.component'; + +type Props = { + onClose: () => void; +}; + +export default function CinemaDetailPanel({ onClose }: Props) { + return ( + +
+ + + + + + + +
+
+ ); +} diff --git a/src/app/parties/(room)/[id]/_panels/cinema-playlist-panel.component.tsx b/src/app/parties/(room)/[id]/_panels/cinema-playlist-panel.component.tsx new file mode 100644 index 00000000..f55fe443 --- /dev/null +++ b/src/app/parties/(room)/[id]/_panels/cinema-playlist-panel.component.tsx @@ -0,0 +1,142 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { usePlaylistAction } from '@/entities/playlist'; +import { AddPlaylistButton } from '@/features/playlist/add'; +import { AddTracksToPlaylist } from '@/features/playlist/add-tracks'; +import { Playlists, EditablePlaylists, PlaylistListItem } from '@/features/playlist/list'; +import { TracksInPlaylist } from '@/features/playlist/list-tracks'; +import { RemovePlaylistButton } from '@/features/playlist/remove'; +import { Playlist } from '@/shared/api/http/types/playlists'; +import { useI18n } from '@/shared/lib/localization/i18n.context'; +import { VariableProcessor } from '@/shared/lib/localization/renderer'; +import { Trans } from '@/shared/lib/localization/renderer/index.ui'; +import { useStores } from '@/shared/lib/store/stores.context'; +import { Button } from '@/shared/ui/components/button'; +import { TextButton } from '@/shared/ui/components/text-button'; +import { PFAdd, PFArrowLeft, PFClose } from '@/shared/ui/icons'; + +type Props = { + onClose: () => void; +}; + +export default function CinemaPlaylistPanel({ onClose }: Props) { + const t = useI18n(); + const { useUIState } = useStores(); + const { playlistDrawer, setPlaylistDrawer } = useUIState(); + const { list: playlists } = usePlaylistAction(); + const [editMode, setEditMode] = useState(false); + const [removeTargets, setRemoveTargets] = useState([]); + + useEffect(() => { + if (!editMode) setRemoveTargets([]); + }, [editMode]); + + useEffect(() => { + if (playlistDrawer.selectedPlaylist) { + const recent = playlists.find((p) => p.id === playlistDrawer.selectedPlaylist?.id); + setPlaylistDrawer({ selectedPlaylist: recent }); + } + }, [playlists]); + + const selectPlaylist = (playlist: Playlist) => setPlaylistDrawer({ selectedPlaylist: playlist }); + const unselectPlaylist = () => setPlaylistDrawer({ selectedPlaylist: undefined }); + + const header = ( +
+
+ {playlistDrawer.selectedPlaylist && ( + } /> + )} + + {playlistDrawer.selectedPlaylist + ? playlistDrawer.selectedPlaylist.name + : t.playlist.title.my_playlist} + +
+ +
+ ); + + if (playlistDrawer.selectedPlaylist) { + return ( +
+ {header} +
+ + {({ text, execute }) => ( + + )} + +
+ + } + /> +
+ +
+
+ ); + } + + if (editMode) { + return ( +
+ {header} +
+ setRemoveTargets([])} /> + setEditMode(false)}> + {t.common.btn.complete} + +
+ +
+ ); + } + + return ( +
+ {header} +
+
+ + {({ text, execute }) => ( + + )} + + +
+ setEditMode(true)}>{t.common.btn.settings} +
+ +
+ ); +} diff --git a/src/app/parties/(room)/[id]/page.tsx b/src/app/parties/(room)/[id]/page.tsx index 36702c9a..d3e9c964 100644 --- a/src/app/parties/(room)/[id]/page.tsx +++ b/src/app/parties/(room)/[id]/page.tsx @@ -1,7 +1,10 @@ 'use client'; import { useParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; import { useCurrentPartyroomAlerts } from '@/entities/current-partyroom'; +import { useSuspenseFetchMe } from '@/entities/me'; import { useIsGuest } from '@/entities/me'; +import { ProfileEditFormV2 } from '@/features/edit-profile-bio'; import { useFetchPartyroomDetailSummary } from '@/features/partyroom/get-summary'; import { useSharePartyroom } from '@/features/partyroom/share-link'; import { useInformSocialType } from '@/features/sign-in/by-social'; @@ -9,17 +12,21 @@ import { cn } from '@/shared/lib/functions/cn'; import { useDisclosure } from '@/shared/lib/hooks/use-disclosure.hook'; import { useI18n } from '@/shared/lib/localization/i18n.context'; import { useStores } from '@/shared/lib/store/stores.context'; -import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@/shared/ui/components/tab'; -import { PFChatFilled, PFPersonOutline, PFDj, PFLink } from '@/shared/ui/icons'; +import { Button } from '@/shared/ui/components/button'; +import { useDialog } from '@/shared/ui/components/dialog'; +import Profile from '@/shared/ui/components/profile/profile.component'; +import { Typography } from '@/shared/ui/components/typography'; +import { PFDj, PFHeadset, PFInfoOutline, PFLink } from '@/shared/ui/icons'; import { PartyroomAvatars } from '@/widgets/partyroom-avatars'; -import { PartyroomChatPanel } from '@/widgets/partyroom-chat-panel'; -import { PartyroomCrewsPanel } from '@/widgets/partyroom-crews-panel'; import { PartyroomDetailTrigger } from '@/widgets/partyroom-detail'; import { PartyroomDisplayBoard } from '@/widgets/partyroom-display-board'; import { DjingDialog } from '@/widgets/partyroom-djing-dialog'; import { useOpenEditProfileAvatarDialog } from '@/widgets/partyroom-edit-profile-avatar-dialog'; import { PartyRoomListTrigger } from '@/widgets/partyroom-party-list'; import { Sidebar } from '@/widgets/sidebar'; +import ChatTabPanel from './_panels/chat-tab-panel.component'; +import CinemaDetailPanel from './_panels/cinema-detail-panel.component'; +import CinemaPlaylistPanel from './_panels/cinema-playlist-panel.component'; const PartyroomPage = () => { const t = useI18n(); @@ -32,25 +39,164 @@ const PartyroomPage = () => { const isGuest = useIsGuest(); const informSocialType = useInformSocialType(); + const { data: me } = useSuspenseFetchMe(); const { data: partyroomSummary, isLoading: isPartyroomSummaryLoading } = useFetchPartyroomDetailSummary(Number(params.id), !!params.id); const sharePartyroom = useSharePartyroom(partyroomSummary); const openEditProfileAvatarDialog = useOpenEditProfileAvatarDialog(); - const { useCurrentPartyroom } = useStores(); - const crewsCount = useCurrentPartyroom((state) => state.crews.length); + const { openDialog } = useDialog(); + const { useUIState } = useStores(); + const cinemaView = useUIState((state) => state.cinemaView); + const cinemaChatOpen = useUIState((state) => state.cinemaChatOpen); + const cinemaSidePanel = useUIState((state) => state.cinemaSidePanel); + const setCinemaSidePanel = useUIState((state) => state.setCinemaSidePanel); + + const [boardWidth, setBoardWidth] = useState(512); + + useEffect(() => { + if (!cinemaView) { + setBoardWidth(512); + return; + } + const computeWidth = () => { + setBoardWidth(window.innerWidth - 400 - 80); + }; + computeWidth(); + window.addEventListener('resize', computeWidth); + return () => window.removeEventListener('resize', computeWidth); + }, [cinemaView]); useCurrentPartyroomAlerts(); + const toggleSidePanel = (panel: 'detail' | 'playlist') => { + setCinemaSidePanel(cinemaSidePanel === panel ? 'none' : panel); + }; + + const handleClickProfileButton = async () => { + if (await isGuest()) { + informSocialType(); + return; + } + openDialog((_, onCancel) => ({ + title: ({ defaultClassName }) => ( + + {t.common.btn.my_profile} + + ), + titleAlign: 'left', + showCloseIcon: true, + classNames: { + container: 'w-[620px] h-[391px] py-7 px-10 bg-black', + }, + Body: ( + { + openEditProfileAvatarDialog(); + onCancel?.(); + }} + /> + ), + })); + }; + + /** Cinema header: sidebar action buttons (profile, headset, dj, link) */ + const sidebarActions = ( +
+ + + + +
+ ); + + /** Cinema header: right-side action buttons */ + const headerActions = ( +
+ {cinemaView ? ( + + ) : ( + + )} + +
+ ); + + /** Cinema side panel content */ + const sidePanelContent = + cinemaSidePanel === 'detail' ? ( + setCinemaSidePanel('none')} /> + ) : cinemaSidePanel === 'playlist' ? ( + setCinemaSidePanel('none')} /> + ) : undefined; + + /** Cinema chat panel — rendered as right column inside the overlay (video+footer shrink left) */ + const chatPanelContent = cinemaChatOpen ? ( + + ) : undefined; + // TODO: 파티룸 모든 api 불러오는 동안 Suspense로 입장 중 페이지 보여주기 return ( <> {/* 가운데 전광판 */} -
- +
+
{/* 왼쪽 float 메뉴 */} @@ -58,6 +204,7 @@ const PartyroomPage = () => { className={cn([ 'flexCol justify-between gap-10 px-1 py-6 bg-[#0E0E0E] rounded', 'absolute top-1/2 left-[40px] transform -translate-y-1/2', + cinemaView && 'hidden', ])} extraButtons={[ { @@ -81,36 +228,20 @@ const PartyroomPage = () => { onClickAvatarSetting={openEditProfileAvatarDialog} /> - {/* 오른쪽 채팅창 */} -
+ {/* 오른쪽 채팅창 — cinema 모드에서는 overlay 내부에서 관리하므로 숨김 */} +
{/* 채팅, 사람 탭 */} - - - } - /> - } - /> - - - - - - - - - - +
-