Compare commits

...

5 Commits

Author SHA1 Message Date
91e32f3f1c fix(musik): fix seek slider reset ke 0 - root cause: useEffect dependency
ROOT CAUSE:
- filteredMusik di-calculate ulang setiap render (.filter() tanpa memoization)
- currentSong = filteredMusik[currentSongIndex] → object reference baru setiap render
- useEffect dependency [currentSong, currentSongIndex] trigger setiap render
- useEffect reset setCurrentTime(0) → slider kembali ke awal

FIX:
1. useMemo untuk filteredMusik - mencegah re-calculate setiap render
2. useEffect dependency [currentSong?.id, currentSongIndex] - hanya trigger saat lagu benar-benar berubah
3. Hapus semua debug console.log yang tidak diperlukan
4. Simplifikasi seekTo function

File Changed:
- src/app/darmasaba/(pages)/musik/musik-desa/page.tsx
- src/app/darmasaba/(pages)/musik/lib/seek.ts

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-02 12:03:26 +08:00
4d03908f23 feat(musik): tambahkan extensive debug logging untuk tracking seek issue
- Tambah key stabil pada audio element untuk mencegah remount
- Log di seekTo: before/after currentTime
- Log di onTimeUpdate: currentTime, rounded, isSeeking
- Log di onChangeEnd slider: value, seekTime
- Log di useEffect song change: currentSong, currentSongIndex
- Log di skipBack/skipForward: index perubahan lagu

Purpose: Track urutan eksekusi dan identifikasi race condition atau re-render yang tidak diinginkan

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-02 11:58:40 +08:00
0563f9664f fix(musik): perbaiki timing dan rounding pada seek slider
- Gunakan durasi dari database sebagai acuan utama (bukan dari audio metadata)
- Ganti Math.floor dengan Math.round untuk smoothing currentTime
- Tambahkan validasi seek time: Math.min(Math.max(0, v), duration)
- Tambahkan debug logging untuk tracking seek behavior
- Hapus override duration di onLoadedMetadata untuk menghindari konflik

Root cause:
- Duration dari database (string 'MM:SS' → seconds) berbeda dengan audio.duration (float)
- Math.floor menyebabkan lompatan kasar dan kehilangan presisi
- onLoadedMetadata override duration dengan audio.duration yang tidak exact

Fix:
- Database duration = source of truth
- Math.round untuk smoothing tanpa kehilangan presisi
- Validasi bounds untuk mencegah seek negatif atau melebihi durasi

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-02 11:53:36 +08:00
961cc32057 fix(musik): slider seek sekarang berfungsi dengan benar
- Fix slider seek reset ke detik awal saat digeser
- Tambahkan isSeeking state untuk mencegah onTimeUpdate mereset posisi slider
- Implementasi pattern preview/commit untuk seek:
  - onChange: update UI state saja (preview)
  - onChangeEnd: commit ke audio player (commit)
- Update seekTo function untuk support optional setCurrentTime callback
- Terapkan fix ke kedua slider (Sedang Diputar dan bottom player)

Bug: Slider seek langsung kembali ke posisi awal saat digeser karena:
1. onTimeUpdate terus menerus update currentTime state
2. seekTo tidak update React state setelah set audio.currentTime
3. Tidak ada isSeeking flag untuk block onTimeUpdate saat user sedang seek

Fix:
1. Set isSeeking=true saat onChange, false saat onChangeEnd
2. onTimeUpdate check isSeeking sebelum update state
3. seekTo sekarang juga update state via callback optional

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-02 11:47:05 +08:00
fe7672e09f refactor(musik): integrate music player library functions and fix build errors
- Integrate togglePlayPause, getNextIndex, getPrevIndex, handleRepeatOrNext, seekTo, toggleShuffle, setAudioVolume, toggleMute library functions
- Fix ESLint warnings: remove unused eslint-disable, add missing useEffect dependencies
- Fix ESLint error in useMusicPlayer.ts togglePlayPause function
- Add force-dynamic export to root layout to prevent prerendering errors
- Improve seek slider with preview/commit functionality
- Add isSeeking state to prevent UI flickering during seek

Fixes: Build PageNotFoundError for admin/darmasaba pages

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-02 11:41:14 +08:00
10 changed files with 244 additions and 97 deletions

View File

@@ -19,7 +19,6 @@ const nextConfig: NextConfig = {
},
];
},
};
export default nextConfig;

BIN
public/mp3-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View 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);

View 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)
// }

View 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)
// }

View 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));
}
}

View File

@@ -0,0 +1,6 @@
export function toggleShuffle(
isShuffle: boolean,
setIsShuffle: (v: boolean) => void
) {
setIsShuffle(!isShuffle);
}

View 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);
}

View File

@@ -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 }}

View File

@@ -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",