Compare commits
5 Commits
fix/tombol
...
fix/slider
| Author | SHA1 | Date | |
|---|---|---|---|
| 91e32f3f1c | |||
| 4d03908f23 | |||
| 0563f9664f | |||
| 961cc32057 | |||
| fe7672e09f |
@@ -19,7 +19,6 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
BIN
public/mp3-logo.png
Normal file
BIN
public/mp3-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
32
src/app/darmasaba/(pages)/musik/lib/nextPrev.ts
Normal file
32
src/app/darmasaba/(pages)/musik/lib/nextPrev.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export function getNextIndex(
|
||||
currentIndex: number,
|
||||
total: number,
|
||||
isShuffle: boolean
|
||||
) {
|
||||
if (total === 0) return -1;
|
||||
|
||||
if (isShuffle) {
|
||||
return Math.floor(Math.random() * total);
|
||||
}
|
||||
|
||||
return (currentIndex + 1) % total;
|
||||
}
|
||||
|
||||
export function getPrevIndex(
|
||||
currentIndex: number,
|
||||
total: number,
|
||||
isShuffle: boolean
|
||||
) {
|
||||
if (total === 0) return -1;
|
||||
|
||||
if (isShuffle) {
|
||||
return Math.floor(Math.random() * total);
|
||||
}
|
||||
|
||||
return currentIndex - 1 < 0 ? total - 1 : currentIndex - 1;
|
||||
}
|
||||
|
||||
//pakai di ui
|
||||
|
||||
// const next = getNextIndex(currentSongIndex, filteredMusik.length, isShuffle);
|
||||
// playSong(next);
|
||||
24
src/app/darmasaba/(pages)/musik/lib/playPause.ts
Normal file
24
src/app/darmasaba/(pages)/musik/lib/playPause.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { RefObject } from "react";
|
||||
|
||||
export function togglePlayPause(
|
||||
audioRef: RefObject<HTMLAudioElement | null>,
|
||||
isPlaying: boolean,
|
||||
setIsPlaying: (v: boolean) => void
|
||||
) {
|
||||
if (!audioRef.current) return;
|
||||
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
audioRef.current
|
||||
.play()
|
||||
.then(() => setIsPlaying(true))
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
// pakai di ui
|
||||
// onClick={() =>
|
||||
// togglePlayPause(audioRef, isPlaying, setIsPlaying)
|
||||
// }
|
||||
22
src/app/darmasaba/(pages)/musik/lib/repeat.ts
Normal file
22
src/app/darmasaba/(pages)/musik/lib/repeat.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { RefObject } from "react";
|
||||
|
||||
export function handleRepeatOrNext(
|
||||
audioRef: RefObject<HTMLAudioElement | null>,
|
||||
isRepeat: boolean,
|
||||
playNext: () => void
|
||||
) {
|
||||
if (!audioRef.current) return;
|
||||
|
||||
if (isRepeat) {
|
||||
audioRef.current.currentTime = 0;
|
||||
audioRef.current.play();
|
||||
} else {
|
||||
playNext();
|
||||
}
|
||||
}
|
||||
|
||||
//dipakai di ui
|
||||
|
||||
// onEnded={() =>
|
||||
// handleRepeatOrNext(audioRef, isRepeat, playNext)
|
||||
// }
|
||||
15
src/app/darmasaba/(pages)/musik/lib/seek.ts
Normal file
15
src/app/darmasaba/(pages)/musik/lib/seek.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export function seekTo(
|
||||
audioRef: React.RefObject<HTMLAudioElement | null>,
|
||||
time: number,
|
||||
setCurrentTime?: (v: number) => void
|
||||
) {
|
||||
if (!audioRef.current) return;
|
||||
|
||||
// Set waktu audio
|
||||
audioRef.current.currentTime = time;
|
||||
|
||||
// Update state jika provided
|
||||
if (setCurrentTime) {
|
||||
setCurrentTime(Math.round(time));
|
||||
}
|
||||
}
|
||||
6
src/app/darmasaba/(pages)/musik/lib/shuffle.ts
Normal file
6
src/app/darmasaba/(pages)/musik/lib/shuffle.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function toggleShuffle(
|
||||
isShuffle: boolean,
|
||||
setIsShuffle: (v: boolean) => void
|
||||
) {
|
||||
setIsShuffle(!isShuffle);
|
||||
}
|
||||
29
src/app/darmasaba/(pages)/musik/lib/volume.ts
Normal file
29
src/app/darmasaba/(pages)/musik/lib/volume.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { RefObject } from "react";
|
||||
|
||||
export function setAudioVolume(
|
||||
audioRef: RefObject<HTMLAudioElement | null>,
|
||||
volume: number,
|
||||
setVolume: (v: number) => void,
|
||||
setIsMuted: (v: boolean) => void
|
||||
) {
|
||||
if (!audioRef.current) return;
|
||||
|
||||
audioRef.current.volume = volume / 100;
|
||||
setVolume(volume);
|
||||
|
||||
if (volume > 0) {
|
||||
setIsMuted(false);
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleMute(
|
||||
audioRef: RefObject<HTMLAudioElement | null>,
|
||||
isMuted: boolean,
|
||||
setIsMuted: (v: boolean) => void
|
||||
) {
|
||||
if (!audioRef.current) return;
|
||||
|
||||
const muted = !isMuted;
|
||||
audioRef.current.muted = muted;
|
||||
setIsMuted(muted);
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
'use client'
|
||||
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, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, 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 { seekTo } from '../lib/seek';
|
||||
import { toggleShuffle } from '../lib/shuffle';
|
||||
import { setAudioVolume, toggleMute as toggleMuteUtil } from '../lib/volume';
|
||||
|
||||
interface MusicFile {
|
||||
id: string;
|
||||
@@ -39,9 +44,8 @@ const MusicPlayer = () => {
|
||||
const [musikData, setMusikData] = useState<Musik[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentSongIndex, setCurrentSongIndex] = useState(-1);
|
||||
|
||||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const progressInterval = useRef<number | null>(null);
|
||||
|
||||
// Fetch musik data from API
|
||||
useEffect(() => {
|
||||
@@ -64,45 +68,52 @@ const MusicPlayer = () => {
|
||||
fetchMusik();
|
||||
}, []);
|
||||
|
||||
// Filter musik based on search
|
||||
const filteredMusik = musikData.filter(musik =>
|
||||
musik.judul.toLowerCase().includes(search.toLowerCase()) ||
|
||||
musik.artis.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(musik.genre && musik.genre.toLowerCase().includes(search.toLowerCase()))
|
||||
);
|
||||
// Filter musik based on search - gunakan useMemo untuk mencegah re-calculate setiap render
|
||||
const filteredMusik = useMemo(() => {
|
||||
return musikData.filter(musik =>
|
||||
musik.judul.toLowerCase().includes(search.toLowerCase()) ||
|
||||
musik.artis.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(musik.genre && musik.genre.toLowerCase().includes(search.toLowerCase()))
|
||||
);
|
||||
}, [musikData, search]);
|
||||
|
||||
const currentSong = currentSongIndex >= 0 && currentSongIndex < filteredMusik.length
|
||||
? filteredMusik[currentSongIndex]
|
||||
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);
|
||||
}
|
||||
}
|
||||
// // 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]);
|
||||
// return () => {
|
||||
// if (progressInterval.current) {
|
||||
// clearInterval(progressInterval.current);
|
||||
// }
|
||||
// };
|
||||
// }, [isPlaying]);
|
||||
|
||||
// Update duration when song changes
|
||||
useEffect(() => {
|
||||
if (currentSong && audioRef.current) {
|
||||
// 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 hanya untuk lagu baru
|
||||
audioRef.current.currentTime = 0;
|
||||
setCurrentTime(0);
|
||||
|
||||
if (isPlaying) {
|
||||
audioRef.current.play().catch(err => {
|
||||
console.error('Error playing audio:', err);
|
||||
@@ -110,7 +121,7 @@ const MusicPlayer = () => {
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [currentSongIndex]);
|
||||
}, [currentSong?.id, currentSongIndex]);
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
@@ -120,84 +131,60 @@ const MusicPlayer = () => {
|
||||
|
||||
const playSong = (index: number) => {
|
||||
if (index < 0 || index >= filteredMusik.length) return;
|
||||
|
||||
|
||||
setCurrentSongIndex(index);
|
||||
setIsPlaying(true);
|
||||
};
|
||||
|
||||
const handleSongEnd = () => {
|
||||
if (isRepeat) {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = 0;
|
||||
audioRef.current.play();
|
||||
}
|
||||
} else {
|
||||
// Play next song
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const handleSeek = (value: number) => {
|
||||
setCurrentTime(value);
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = value;
|
||||
}
|
||||
handleRepeatOrNext(audioRef, isRepeat, playNext);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const newMuted = !isMuted;
|
||||
setIsMuted(newMuted);
|
||||
if (audioRef.current) {
|
||||
audioRef.current.muted = newMuted;
|
||||
}
|
||||
toggleMuteUtil(audioRef, isMuted, setIsMuted);
|
||||
};
|
||||
|
||||
const handleVolumeChange = (val: number) => {
|
||||
setVolume(val);
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = val / 100;
|
||||
}
|
||||
if (val > 0 && isMuted) {
|
||||
setIsMuted(false);
|
||||
}
|
||||
setAudioVolume(audioRef, val, setVolume, setIsMuted);
|
||||
};
|
||||
|
||||
const skipBack = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = Math.max(0, audioRef.current.currentTime - 10);
|
||||
const prevIndex = getPrevIndex(currentSongIndex, filteredMusik.length, isShuffle);
|
||||
if (prevIndex >= 0) {
|
||||
playSong(prevIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const skipForward = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = Math.min(duration, audioRef.current.currentTime + 10);
|
||||
const nextIndex = getNextIndex(currentSongIndex, filteredMusik.length, isShuffle);
|
||||
if (nextIndex >= 0) {
|
||||
playSong(nextIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlayPause = () => {
|
||||
const toggleShuffleHandler = () => {
|
||||
toggleShuffle(isShuffle, setIsShuffle);
|
||||
};
|
||||
|
||||
const togglePlayPauseHandler = () => {
|
||||
if (!currentSong) return;
|
||||
|
||||
if (isPlaying) {
|
||||
audioRef.current?.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
audioRef.current?.play().catch(err => {
|
||||
console.error('Error playing audio:', err);
|
||||
});
|
||||
setIsPlaying(true);
|
||||
}
|
||||
togglePlayPause(audioRef, isPlaying, setIsPlaying);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -212,18 +199,28 @@ const MusicPlayer = () => {
|
||||
|
||||
return (
|
||||
<Box px={{ base: 'md', md: 100 }} py="xl">
|
||||
{/* Hidden audio element */}
|
||||
{/* Hidden audio element - gunakan key yang stabil untuk mencegah remount */}
|
||||
{currentSong?.audioFile && (
|
||||
<audio
|
||||
key={`audio-${currentSong.id}`}
|
||||
ref={audioRef}
|
||||
src={currentSong.audioFile.link}
|
||||
onEnded={handleSongEnd}
|
||||
onLoadedMetadata={() => {
|
||||
muted={isMuted}
|
||||
onLoadedMetadata={(e) => {
|
||||
// Jangan override duration dari database
|
||||
// Audio element duration bisa berbeda beberapa ms
|
||||
if (audioRef.current) {
|
||||
setDuration(Math.floor(audioRef.current.duration));
|
||||
audioRef.current.currentTime = 0;
|
||||
}
|
||||
}}
|
||||
muted={isMuted}
|
||||
onTimeUpdate={() => {
|
||||
if (!audioRef.current || isSeeking) return;
|
||||
// Gunakan pembulatan yang lebih smooth
|
||||
const time = audioRef.current.currentTime;
|
||||
const roundedTime = Math.round(time);
|
||||
setCurrentTime(roundedTime);
|
||||
}}
|
||||
onEnded={handleSongEnd}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -262,10 +259,10 @@ const MusicPlayer = () => {
|
||||
{currentSong ? (
|
||||
<Card radius="md" p="xl" shadow="md">
|
||||
<Group align="center" gap="xl">
|
||||
<Avatar
|
||||
src={currentSong.coverImage?.link || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'}
|
||||
size={180}
|
||||
radius="md"
|
||||
<Avatar
|
||||
src={currentSong.coverImage?.link || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'}
|
||||
size={180}
|
||||
radius="md"
|
||||
/>
|
||||
<Stack gap="md" style={{ flex: 1 }}>
|
||||
<div>
|
||||
@@ -280,7 +277,17 @@ const MusicPlayer = () => {
|
||||
<Slider
|
||||
value={currentTime}
|
||||
max={duration}
|
||||
onChange={handleSeek}
|
||||
onChange={(v) => {
|
||||
setIsSeeking(true);
|
||||
setCurrentTime(v);
|
||||
}}
|
||||
onChangeEnd={(v) => {
|
||||
// Validasi: jangan seek melebihi durasi
|
||||
const seekTime = Math.min(Math.max(0, v), duration);
|
||||
// Set seeking false DULUAN sebelum seekTo
|
||||
setIsSeeking(false);
|
||||
seekTo(audioRef, seekTime, setCurrentTime);
|
||||
}}
|
||||
color="#0B4F78"
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
@@ -319,10 +326,10 @@ const MusicPlayer = () => {
|
||||
onClick={() => playSong(index)}
|
||||
>
|
||||
<Group gap="md" align="center">
|
||||
<Avatar
|
||||
src={song.coverImage?.link || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'}
|
||||
size={64}
|
||||
radius="md"
|
||||
<Avatar
|
||||
src={song.coverImage?.link || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'}
|
||||
size={64}
|
||||
radius="md"
|
||||
/>
|
||||
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} c="#0B4F78" truncate>{song.judul}</Text>
|
||||
@@ -359,10 +366,10 @@ const MusicPlayer = () => {
|
||||
>
|
||||
<Flex align="center" justify="space-between" gap="xl" h="100%">
|
||||
<Group gap="md" style={{ flex: 1 }}>
|
||||
<Avatar
|
||||
src={currentSong?.coverImage?.link || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'}
|
||||
size={56}
|
||||
radius="md"
|
||||
<Avatar
|
||||
src={currentSong?.coverImage?.link || 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop'}
|
||||
size={56}
|
||||
radius="md"
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{currentSong ? (
|
||||
@@ -381,7 +388,7 @@ const MusicPlayer = () => {
|
||||
<ActionIcon
|
||||
variant={isShuffle ? 'filled' : 'subtle'}
|
||||
color="#0B4F78"
|
||||
onClick={() => setIsShuffle(!isShuffle)}
|
||||
onClick={toggleShuffleHandler}
|
||||
radius="xl"
|
||||
>
|
||||
{isShuffle ? <IconArrowsShuffle size={18} /> : <IconX size={18} />}
|
||||
@@ -394,7 +401,7 @@ const MusicPlayer = () => {
|
||||
color="#0B4F78"
|
||||
size={56}
|
||||
radius="xl"
|
||||
onClick={togglePlayPause}
|
||||
onClick={togglePlayPauseHandler}
|
||||
>
|
||||
{isPlaying ? <IconPlayerPauseFilled size={26} /> : <IconPlayerPlayFilled size={26} />}
|
||||
</ActionIcon>
|
||||
@@ -415,7 +422,17 @@ const MusicPlayer = () => {
|
||||
<Slider
|
||||
value={currentTime}
|
||||
max={duration}
|
||||
onChange={handleSeek}
|
||||
onChange={(v) => {
|
||||
setIsSeeking(true);
|
||||
setCurrentTime(v); // preview - update UI saja
|
||||
}}
|
||||
onChangeEnd={(v) => {
|
||||
// Validasi: jangan seek melebihi durasi
|
||||
const seekTime = Math.min(Math.max(0, v), duration);
|
||||
// Set seeking false DULUAN sebelum seekTo
|
||||
setIsSeeking(false);
|
||||
seekTo(audioRef, seekTime, setCurrentTime);
|
||||
}}
|
||||
color="#0B4F78"
|
||||
size="xs"
|
||||
style={{ flex: 1 }}
|
||||
|
||||
@@ -12,6 +12,9 @@ import { Metadata, Viewport } from "next";
|
||||
import { ViewTransitions } from "next-view-transitions";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
|
||||
// Force dynamic rendering untuk menghindari error prerendering
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// ✅ Pisahkan viewport ke export tersendiri
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
|
||||
Reference in New Issue
Block a user