From f63aaf916d824d68913c17742ec14e1472250bff Mon Sep 17 00:00:00 2001 From: nico Date: Wed, 4 Mar 2026 16:28:06 +0800 Subject: [PATCH] Fix Tabel Apbdes, & fix muciplayer in background --- .../(dashboard)/_state/landing-page/apbdes.ts | 2 +- src/app/context/MusicContext.tsx | 313 ++++++++++++++ .../(pages)/musik/musik-desa/page.tsx | 406 +++--------------- src/app/darmasaba/_com/FixedPlayerBar.tsx | 269 ++++++++++++ .../main-page/apbdes/lib/realisasiTable.tsx | 8 +- src/app/darmasaba/layout.tsx | 2 + src/app/layout.tsx | 21 +- 7 files changed, 664 insertions(+), 357 deletions(-) create mode 100644 src/app/context/MusicContext.tsx create mode 100644 src/app/darmasaba/_com/FixedPlayerBar.tsx diff --git a/src/app/admin/(dashboard)/_state/landing-page/apbdes.ts b/src/app/admin/(dashboard)/_state/landing-page/apbdes.ts index 7e05b86e..da26a972 100644 --- a/src/app/admin/(dashboard)/_state/landing-page/apbdes.ts +++ b/src/app/admin/(dashboard)/_state/landing-page/apbdes.ts @@ -104,7 +104,7 @@ const apbdes = proxy({ findMany: { data: null as | Prisma.APBDesGetPayload<{ - include: { image: true; file: true; items: true }; + include: { image: true; file: true; items: { include: { realisasiItems: true } } }; }>[] | null, page: 1, diff --git a/src/app/context/MusicContext.tsx b/src/app/context/MusicContext.tsx new file mode 100644 index 00000000..60119836 --- /dev/null +++ b/src/app/context/MusicContext.tsx @@ -0,0 +1,313 @@ +'use client'; + +import { + createContext, + useContext, + useState, + useRef, + useEffect, + useCallback, + ReactNode, +} from 'react'; + +interface MusicFile { + id: string; + name: string; + realName: string; + path: string; + mimeType: string; + link: string; +} + +export interface Musik { + id: string; + judul: string; + artis: string; + deskripsi: string | null; + durasi: string; + genre: string | null; + tahunRilis: number | null; + audioFile: MusicFile | null; + coverImage: MusicFile | null; + isActive: boolean; +} + +interface MusicContextType { + // State + isPlaying: boolean; + currentSong: Musik | null; + currentSongIndex: number; + musikData: Musik[]; + currentTime: number; + duration: number; + volume: number; + isMuted: boolean; + isRepeat: boolean; + isShuffle: boolean; + isLoading: boolean; + isPlayerOpen: boolean; + + // Actions + playSong: (song: Musik) => void; + togglePlayPause: () => void; + playNext: () => void; + playPrev: () => void; + seek: (time: number) => void; + setVolume: (volume: number) => void; + toggleMute: () => void; + toggleRepeat: () => void; + toggleShuffle: () => void; + togglePlayer: () => void; + loadMusikData: () => Promise; +} + +const MusicContext = createContext(undefined); + +export function MusicProvider({ children }: { children: ReactNode }) { + // State + const [isPlaying, setIsPlaying] = useState(false); + const [currentSong, setCurrentSong] = useState(null); + const [currentSongIndex, setCurrentSongIndex] = useState(-1); + const [musikData, setMusikData] = useState([]); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [volume, setVolumeState] = useState(70); + const [isMuted, setIsMuted] = useState(false); + const [isRepeat, setIsRepeat] = useState(false); + const [isShuffle, setIsShuffle] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isPlayerOpen, setIsPlayerOpen] = useState(false); + + // Refs + const audioRef = useRef(null); + const isSeekingRef = useRef(false); + const animationFrameRef = useRef(null); + + // Load musik data + const loadMusikData = useCallback(async () => { + try { + setIsLoading(true); + const res = await fetch('/api/desa/musik/find-many?page=1&limit=50'); + const data = await res.json(); + if (data.success && data.data) { + const activeMusik = data.data.filter((m: Musik) => m.isActive); + setMusikData(activeMusik); + } + } catch (error) { + console.error('Error fetching musik:', error); + } finally { + setIsLoading(false); + } + }, []); + + // Initialize audio element + useEffect(() => { + audioRef.current = new Audio(); + audioRef.current.preload = 'metadata'; + + // Event listeners + audioRef.current.addEventListener('loadedmetadata', () => { + setDuration(Math.floor(audioRef.current!.duration)); + }); + + audioRef.current.addEventListener('ended', () => { + if (isRepeat) { + audioRef.current!.currentTime = 0; + audioRef.current!.play(); + } else { + playNext(); + } + }); + + // Load initial data + loadMusikData(); + + return () => { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- playNext is intentionally not in deps to avoid circular dependency + }, [loadMusikData, isRepeat]); + + // Update time with requestAnimationFrame for smooth progress + const updateTime = useCallback(() => { + if (audioRef.current && !audioRef.current.paused && !isSeekingRef.current) { + setCurrentTime(Math.floor(audioRef.current.currentTime)); + animationFrameRef.current = requestAnimationFrame(updateTime); + } + }, []); + + useEffect(() => { + if (isPlaying) { + animationFrameRef.current = requestAnimationFrame(updateTime); + } else { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + } + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [isPlaying, updateTime]); + + // Play song + const playSong = useCallback( + (song: Musik) => { + if (!song?.audioFile?.link || !audioRef.current) return; + + const songIndex = musikData.findIndex(m => m.id === song.id); + setCurrentSongIndex(songIndex); + setCurrentSong(song); + setIsPlaying(true); + + audioRef.current.src = song.audioFile.link; + audioRef.current.load(); + audioRef.current + .play() + .catch((err) => console.error('Error playing audio:', err)); + }, + [musikData] + ); + + // Toggle play/pause + const togglePlayPause = useCallback(() => { + if (!audioRef.current || !currentSong) return; + + if (isPlaying) { + audioRef.current.pause(); + setIsPlaying(false); + } else { + audioRef.current + .play() + .then(() => setIsPlaying(true)) + .catch((err) => console.error('Error playing audio:', err)); + } + }, [isPlaying, currentSong]); + + // Play next + const playNext = useCallback(() => { + if (musikData.length === 0) return; + + let nextIndex: number; + if (isShuffle) { + nextIndex = Math.floor(Math.random() * musikData.length); + } else { + nextIndex = (currentSongIndex + 1) % musikData.length; + } + const nextSong = musikData[nextIndex]; + if (nextSong) { + playSong(nextSong); + } + }, [musikData, isShuffle, currentSongIndex, playSong]); + + // Play previous + const playPrev = useCallback(() => { + if (musikData.length === 0) return; + + // If more than 3 seconds into song, restart it + if (currentTime > 3) { + if (audioRef.current) { + audioRef.current.currentTime = 0; + } + return; + } + + const prevIndex = + currentSongIndex <= 0 ? musikData.length - 1 : currentSongIndex - 1; + const prevSong = musikData[prevIndex]; + if (prevSong) { + playSong(prevSong); + } + }, [musikData, currentSongIndex, currentTime, playSong]); + + // Seek + const seek = useCallback((time: number) => { + if (!audioRef.current) return; + audioRef.current.currentTime = time; + setCurrentTime(time); + }, []); + + // Set volume + const setVolume = useCallback((vol: number) => { + if (!audioRef.current) return; + const normalizedVol = Math.max(0, Math.min(100, vol)) / 100; + audioRef.current.volume = normalizedVol; + setVolumeState(Math.max(0, Math.min(100, vol))); + setIsMuted(normalizedVol === 0); + }, []); + + // Toggle mute + const toggleMute = useCallback(() => { + if (!audioRef.current) return; + + const newMuted = !isMuted; + audioRef.current.muted = newMuted; + setIsMuted(newMuted); + + if (newMuted && volume > 0) { + audioRef.current.volume = 0; + } else if (!newMuted && volume > 0) { + audioRef.current.volume = volume / 100; + } + }, [isMuted, volume]); + + // Toggle repeat + const toggleRepeat = useCallback(() => { + setIsRepeat((prev) => !prev); + }, []); + + // Toggle shuffle + const toggleShuffle = useCallback(() => { + setIsShuffle((prev) => !prev); + }, []); + + // Toggle player + const togglePlayer = useCallback(() => { + setIsPlayerOpen((prev) => !prev); + }, []); + + const value: MusicContextType = { + isPlaying, + currentSong, + currentSongIndex, + musikData, + currentTime, + duration, + volume, + isMuted, + isRepeat, + isShuffle, + isLoading, + isPlayerOpen, + playSong, + togglePlayPause, + playNext, + playPrev, + seek, + setVolume, + toggleMute, + toggleRepeat, + toggleShuffle, + togglePlayer, + loadMusikData, + }; + + return ( + {children} + ); +} + +export function useMusic() { + const context = useContext(MusicContext); + if (context === undefined) { + throw new Error('useMusic must be used within a MusicProvider'); + } + return context; +} diff --git a/src/app/darmasaba/(pages)/musik/musik-desa/page.tsx b/src/app/darmasaba/(pages)/musik/musik-desa/page.tsx index 45934129..7e20e2f2 100644 --- a/src/app/darmasaba/(pages)/musik/musik-desa/page.tsx +++ b/src/app/darmasaba/(pages)/musik/musik-desa/page.tsx @@ -1,77 +1,41 @@ 'use client' +import { useMusic } from '@/app/context/MusicContext'; import { ActionIcon, Avatar, Badge, Box, Card, Flex, Grid, Group, Paper, ScrollArea, Slider, Stack, Text, TextInput } from '@mantine/core'; import { IconArrowsShuffle, IconPlayerPauseFilled, IconPlayerPlayFilled, IconPlayerSkipBackFilled, IconPlayerSkipForwardFilled, IconRepeat, IconRepeatOff, IconSearch, IconVolume, IconVolumeOff, IconX } from '@tabler/icons-react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import BackButton from '../../desa/layanan/_com/BackButto'; -import { togglePlayPause } from '../lib/playPause'; -import { getNextIndex, getPrevIndex } from '../lib/nextPrev'; -import { handleRepeatOrNext } from '../lib/repeat'; -import { toggleShuffle } from '../lib/shuffle'; -import { setAudioVolume, toggleMute as toggleMuteUtil } from '../lib/volume'; -import { useAudioProgress } from '../lib/useAudioProgress'; - -interface MusicFile { - id: string; - name: string; - realName: string; - path: string; - mimeType: string; - link: string; -} - -interface Musik { - id: string; - judul: string; - artis: string; - deskripsi: string | null; - durasi: string; - genre: string | null; - tahunRilis: number | null; - audioFile: MusicFile | null; - coverImage: MusicFile | null; - isActive: boolean; -} const MusicPlayer = () => { - const [isPlaying, setIsPlaying] = useState(false); - const [currentTime, setCurrentTime] = useState(0); - const [duration, setDuration] = useState(0); - const [volume, setVolume] = useState(70); - const [isMuted, setIsMuted] = useState(false); - const [isRepeat, setIsRepeat] = useState(false); - const [isShuffle, setIsShuffle] = useState(false); + const { + isPlaying, + currentSong, + currentTime, + duration, + volume, + isMuted, + isRepeat, + isShuffle, + isLoading, + musikData, + playSong, + togglePlayPause, + playNext, + playPrev, + seek, + setVolume, + toggleMute, + toggleRepeat, + toggleShuffle, + } = useMusic(); + const [search, setSearch] = useState(''); - const [musikData, setMusikData] = useState([]); - const [loading, setLoading] = useState(true); - const [currentSongIndex, setCurrentSongIndex] = useState(-1); - const audioRef = useRef(null); - const isSeekingRef = useRef(false); - const lastPlayedSongIdRef = useRef(null); - const lastSeekTimeRef = useRef(0); // Track last seek time - // Smooth progress update dengan requestAnimationFrame - useAudioProgress(audioRef as React.RefObject, isPlaying, setCurrentTime, isSeekingRef); + // Fetch musik data from global state + const { loadMusikData } = useMusic(); - // Fetch musik data from API useEffect(() => { - const fetchMusik = async () => { - try { - setLoading(true); - const res = await fetch('/api/desa/musik/find-many?page=1&limit=50'); - const data = await res.json(); - if (data.success && data.data) { - const activeMusik = data.data.filter((m: Musik) => m.isActive); - setMusikData(activeMusik); - } - } catch (error) { - console.error('Error fetching musik:', error); - } finally { - setLoading(false); - } - }; - - fetchMusik(); - }, []); + loadMusikData(); + }, [loadMusikData]); // Filter musik based on search - gunakan useMemo untuk mencegah re-calculate setiap render const filteredMusik = useMemo(() => { @@ -82,146 +46,42 @@ const MusicPlayer = () => { ); }, [musikData, search]); - const currentSong = currentSongIndex >= 0 && currentSongIndex < filteredMusik.length - ? filteredMusik[currentSongIndex] - : null; - - // // Update progress bar - // useEffect(() => { - // if (isPlaying && audioRef.current) { - // progressInterval.current = window.setInterval(() => { - // if (audioRef.current) { - // setCurrentTime(Math.floor(audioRef.current.currentTime)); - // } - // }, 1000); - // } else { - // if (progressInterval.current) { - // clearInterval(progressInterval.current); - // } - // } - - // return () => { - // if (progressInterval.current) { - // clearInterval(progressInterval.current); - // } - // }; - // }, [isPlaying]); - - // Update duration when song changes (HANYA saat ganti lagu, bukan saat isPlaying berubah) - useEffect(() => { - if (currentSong && audioRef.current) { - // Cek apakah ini benar-benar lagu baru - const isNewSong = lastPlayedSongIdRef.current !== currentSong.id; - - if (isNewSong) { - // Gunakan durasi dari database sebagai acuan utama - const durationParts = currentSong.durasi.split(':'); - const durationInSeconds = parseInt(durationParts[0]) * 60 + parseInt(durationParts[1]); - setDuration(durationInSeconds); - - // Reset audio currentTime ke 0 untuk lagu baru - audioRef.current.currentTime = 0; - setCurrentTime(0); - - // Update ref - lastPlayedSongIdRef.current = currentSong.id; - - if (isPlaying) { - audioRef.current.play().catch(err => { - console.error('Error playing audio:', err); - setIsPlaying(false); - }); - } - } - // Jika bukan lagu baru, jangan reset currentTime (biar seek tidak kembali ke 0) - } - }, [currentSong?.id]); // eslint-disable-line react-hooks/exhaustive-deps -- Intentional: hanya depend on song ID, bukan isPlaying - - // Sync duration dari audio element jika berbeda signifikan (> 1 detik) - useEffect(() => { - const audio = audioRef.current; - if (!audio || !currentSong) return; - - const handleLoadedMetadata = () => { - const audioDuration = Math.floor(audio.duration); - const durationParts = currentSong.durasi.split(':'); - const dbDuration = parseInt(durationParts[0]) * 60 + parseInt(durationParts[1]); - - // Jika perbedaan > 2 detik, gunakan audio duration (lebih akurat) - if (Math.abs(audioDuration - dbDuration) > 2) { - setDuration(audioDuration); - } - }; - - audio.addEventListener('loadedmetadata', handleLoadedMetadata); - return () => audio.removeEventListener('loadedmetadata', handleLoadedMetadata); - }, [currentSong?.id]); // eslint-disable-line react-hooks/exhaustive-deps -- Intentional: hanya depend on song ID - + // Format time helper const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; }; - const playSong = (index: number) => { - if (index < 0 || index >= filteredMusik.length) return; - - setCurrentSongIndex(index); - setIsPlaying(true); + const handleVolumeChange = (value: number) => { + setVolume(value); }; - const handleSongEnd = () => { - const playNext = () => { - let nextIndex: number; - if (isShuffle) { - nextIndex = Math.floor(Math.random() * filteredMusik.length); - } else { - nextIndex = (currentSongIndex + 1) % filteredMusik.length; - } - - if (filteredMusik.length > 1) { - playSong(nextIndex); - } else { - setIsPlaying(false); - setCurrentTime(0); - } - }; - - handleRepeatOrNext(audioRef, isRepeat, playNext); - }; - - const toggleMute = () => { - toggleMuteUtil(audioRef, isMuted, setIsMuted); - }; - - const handleVolumeChange = (val: number) => { - setAudioVolume(audioRef, val, setVolume, setIsMuted); - }; - - const skipBack = () => { - const prevIndex = getPrevIndex(currentSongIndex, filteredMusik.length, isShuffle); - if (prevIndex >= 0) { - playSong(prevIndex); - } - }; - - const skipForward = () => { - const nextIndex = getNextIndex(currentSongIndex, filteredMusik.length, isShuffle); - if (nextIndex >= 0) { - playSong(nextIndex); - } - }; - - const toggleShuffleHandler = () => { - toggleShuffle(isShuffle, setIsShuffle); + const toggleMuteHandler = () => { + toggleMute(); }; const togglePlayPauseHandler = () => { - if (!currentSong) return; - togglePlayPause(audioRef, isPlaying, setIsPlaying); + togglePlayPause(); }; - if (loading) { + const skipBack = () => { + playPrev(); + }; + + const skipForward = () => { + playNext(); + }; + + const toggleShuffleHandler = () => { + toggleShuffle(); + }; + + const toggleRepeatHandler = () => { + toggleRepeat(); + }; + + if (isLoading) { return ( @@ -233,16 +93,6 @@ const MusicPlayer = () => { return ( - {/* Hidden audio element - gunakan key yang stabil untuk mencegah remount */} - {currentSong?.audioFile && ( -