Compare commits

..

4 Commits

Author SHA1 Message Date
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
341ff5779f Fix Durasi Musik Di Tampilan User 2026-02-27 11:52:18 +08:00
69f7b4c162 feat: integrate musik desa page with API and improve audio player
- Fetch musik data from /api/desa/musik/find-many endpoint
- Filter only active musik (isActive: true)
- Add search functionality by title, artist, and genre
- Implement real audio playback with HTML5 audio element
- Add play/pause, next/previous, shuffle, repeat controls
- Add progress bar with seek functionality
- Add volume control with mute toggle
- Auto-play next song when current song ends
- Add loading and empty states
- Use cover image and audio file from database
- Fix skip back/forward button handlers

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-02-26 22:24:25 +08:00
409ad4f1a2 Fix Login KodeOtp WA 2026-02-26 22:10:28 +08:00
18 changed files with 587 additions and 126 deletions

View File

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

View File

@@ -60,7 +60,7 @@ model FileStorage {
deletedAt DateTime? deletedAt DateTime?
isActive Boolean @default(true) isActive Boolean @default(true)
link String link String
category String // "image" / "document" / "other" category String // "image" / "document" / "audio" / "other"
Berita Berita[] Berita Berita[]
PotensiDesa PotensiDesa[] PotensiDesa PotensiDesa[]
Posyandu Posyandu[] Posyandu Posyandu[]

BIN
public/mp3-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor'; import CreateEditor from '../../../_com/createEditor';
import stateDashboardMusik from '@/app/admin/(dashboard)/_state/desa/musik'; import stateDashboardMusik from '../../../_state/desa/musik';
import colors from '@/con/colors'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
import { import {
@@ -37,9 +37,34 @@ export default function EditMusik() {
const [coverFile, setCoverFile] = useState<File | null>(null); const [coverFile, setCoverFile] = useState<File | null>(null);
const [previewAudio, setPreviewAudio] = useState<string | null>(null); const [previewAudio, setPreviewAudio] = useState<string | null>(null);
const [audioFile, setAudioFile] = useState<File | null>(null); const [audioFile, setAudioFile] = useState<File | null>(null);
const [isExtractingDuration, setIsExtractingDuration] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
// Fungsi untuk mendapatkan durasi dari file audio
const getAudioDuration = (file: File): Promise<string> => {
return new Promise((resolve) => {
const audio = new Audio();
const url = URL.createObjectURL(file);
audio.addEventListener('loadedmetadata', () => {
const duration = audio.duration;
const minutes = Math.floor(duration / 60);
const seconds = Math.floor(duration % 60);
const formatted = `${minutes}:${seconds.toString().padStart(2, '0')}`;
URL.revokeObjectURL(url);
resolve(formatted);
});
audio.addEventListener('error', () => {
URL.revokeObjectURL(url);
resolve('0:00');
});
audio.src = url;
});
};
useShallowEffect(() => { useShallowEffect(() => {
if (id) { if (id) {
musikState.musik.edit.load(id).then(() => setIsLoading(false)); musikState.musik.edit.load(id).then(() => setIsLoading(false));
@@ -116,7 +141,7 @@ export default function EditMusik() {
await musikState.musik.edit.update(); await musikState.musik.edit.update();
resetForm(); resetForm();
router.push('/admin/desa/musik'); router.push('/admin/musik');
} catch (error) { } catch (error) {
console.error('Error updating musik:', error); console.error('Error updating musik:', error);
toast.error('Terjadi kesalahan saat mengupdate musik'); toast.error('Terjadi kesalahan saat mengupdate musik');
@@ -295,11 +320,24 @@ export default function EditMusik() {
File Audio File Audio
</Text> </Text>
<Dropzone <Dropzone
onDrop={(files) => { onDrop={async (files) => {
const selectedFile = files[0]; const selectedFile = files[0];
if (selectedFile) { if (selectedFile) {
setAudioFile(selectedFile); setAudioFile(selectedFile);
setPreviewAudio(selectedFile.name); setPreviewAudio(selectedFile.name);
// Extract durasi otomatis dari audio
setIsExtractingDuration(true);
try {
const duration = await getAudioDuration(selectedFile);
musikState.musik.edit.form.durasi = duration;
toast.success(`Durasi audio terdeteksi: ${duration}`);
} catch (error) {
console.error('Error extracting audio duration:', error);
toast.error('Gagal mendeteksi durasi audio, silakan isi manual');
} finally {
setIsExtractingDuration(false);
}
} }
}} }}
onReject={() => toast.error('File tidak valid, gunakan format audio (MP3, WAV, OGG)')} onReject={() => toast.error('File tidak valid, gunakan format audio (MP3, WAV, OGG)')}
@@ -332,6 +370,11 @@ export default function EditMusik() {
<Text fz="sm" truncate style={{ flex: 1 }}> <Text fz="sm" truncate style={{ flex: 1 }}>
{previewAudio || 'File audio tersimpan'} {previewAudio || 'File audio tersimpan'}
</Text> </Text>
{isExtractingDuration && (
<Text fz="xs" c="blue">
Mendeteksi durasi...
</Text>
)}
<ActionIcon <ActionIcon
variant="filled" variant="filled"
color="red" color="red"

View File

@@ -19,7 +19,7 @@ import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import stateDashboardMusik from '../../../_state/desa/musik'; import stateDashboardMusik from '../../_state/desa/musik';
export default function DetailMusik() { export default function DetailMusik() {
const musikState = useProxy(stateDashboardMusik); const musikState = useProxy(stateDashboardMusik);
@@ -63,7 +63,7 @@ export default function DetailMusik() {
setIsDeleting(true); setIsDeleting(true);
await musikState.musik.delete.byId(id); await musikState.musik.delete.byId(id);
setShowDeleteModal(false); setShowDeleteModal(false);
router.push('/admin/desa/musik'); router.push('/admin/musik');
} catch (error) { } catch (error) {
console.error('Error deleting musik:', error); console.error('Error deleting musik:', error);
} finally { } finally {
@@ -77,7 +77,7 @@ export default function DetailMusik() {
<Group mb="md"> <Group mb="md">
<Button <Button
variant="subtle" variant="subtle"
onClick={() => router.push('/admin/desa/musik')} onClick={() => router.push('/admin/musik')}
p="xs" p="xs"
radius="md" radius="md"
> >
@@ -99,15 +99,22 @@ export default function DetailMusik() {
<Stack gap="md"> <Stack gap="md">
{/* Cover Image */} {/* Cover Image */}
{data.coverImage && ( {data.coverImage && (
<Box style={{ textAlign: 'center' }}> <Box
style={{
width: '100%',
maxWidth: 400,
margin: '0 auto',
}}
>
<Image <Image
src={data.coverImage.link} src={data.coverImage.link}
alt={data.judul} alt={data.judul}
radius="md" radius="md"
style={{ style={{
maxHeight: 300, width: '100%',
aspectRatio: '1/1',
objectFit: 'cover', objectFit: 'cover',
margin: '0 auto', display: 'block',
}} }}
/> />
</Box> </Box>
@@ -219,7 +226,7 @@ export default function DetailMusik() {
radius="md" radius="md"
size="md" size="md"
leftSection={<IconEdit size={18} />} leftSection={<IconEdit size={18} />}
onClick={() => router.push(`/admin/desa/musik/${id}/edit`)} onClick={() => router.push(`/admin/musik/${id}/edit`)}
> >
Edit Edit
</Button> </Button>

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor'; import CreateEditor from '../../_com/createEditor';
import stateDashboardMusik from '@/app/admin/(dashboard)/_state/desa/musik'; import stateDashboardMusik from '../../_state/desa/musik';
import colors from '@/con/colors'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
import { import {
@@ -32,9 +32,34 @@ export default function CreateMusik() {
const [coverFile, setCoverFile] = useState<File | null>(null); const [coverFile, setCoverFile] = useState<File | null>(null);
const [previewAudio, setPreviewAudio] = useState<string | null>(null); const [previewAudio, setPreviewAudio] = useState<string | null>(null);
const [audioFile, setAudioFile] = useState<File | null>(null); const [audioFile, setAudioFile] = useState<File | null>(null);
const [isExtractingDuration, setIsExtractingDuration] = useState(false);
const router = useRouter(); const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// Fungsi untuk mendapatkan durasi dari file audio
const getAudioDuration = (file: File): Promise<string> => {
return new Promise((resolve) => {
const audio = new Audio();
const url = URL.createObjectURL(file);
audio.addEventListener('loadedmetadata', () => {
const duration = audio.duration;
const minutes = Math.floor(duration / 60);
const seconds = Math.floor(duration % 60);
const formatted = `${minutes}:${seconds.toString().padStart(2, '0')}`;
URL.revokeObjectURL(url);
resolve(formatted);
});
audio.addEventListener('error', () => {
URL.revokeObjectURL(url);
resolve('0:00');
});
audio.src = url;
});
};
const isFormValid = () => { const isFormValid = () => {
return ( return (
musikState.musik.create.form.judul?.trim() !== '' && musikState.musik.create.form.judul?.trim() !== '' &&
@@ -126,7 +151,7 @@ export default function CreateMusik() {
await musikState.musik.create.create(); await musikState.musik.create.create();
resetForm(); resetForm();
router.push('/admin/desa/musik'); router.push('/admin/musik');
} catch (error) { } catch (error) {
console.error('Error creating musik:', error); console.error('Error creating musik:', error);
toast.error('Terjadi kesalahan saat membuat musik'); toast.error('Terjadi kesalahan saat membuat musik');
@@ -294,11 +319,24 @@ export default function CreateMusik() {
File Audio File Audio
</Text> </Text>
<Dropzone <Dropzone
onDrop={(files) => { onDrop={async (files) => {
const selectedFile = files[0]; const selectedFile = files[0];
if (selectedFile) { if (selectedFile) {
setAudioFile(selectedFile); setAudioFile(selectedFile);
setPreviewAudio(selectedFile.name); setPreviewAudio(selectedFile.name);
// Extract durasi otomatis dari audio
setIsExtractingDuration(true);
try {
const duration = await getAudioDuration(selectedFile);
musikState.musik.create.form.durasi = duration;
toast.success(`Durasi audio terdeteksi: ${duration}`);
} catch (error) {
console.error('Error extracting audio duration:', error);
toast.error('Gagal mendeteksi durasi audio, silakan isi manual');
} finally {
setIsExtractingDuration(false);
}
} }
}} }}
onReject={() => toast.error('File tidak valid, gunakan format audio (MP3, WAV, OGG)')} onReject={() => toast.error('File tidak valid, gunakan format audio (MP3, WAV, OGG)')}
@@ -331,6 +369,11 @@ export default function CreateMusik() {
<Text fz="sm" truncate style={{ flex: 1 }}> <Text fz="sm" truncate style={{ flex: 1 }}>
{previewAudio} {previewAudio}
</Text> </Text>
{isExtractingDuration && (
<Text fz="xs" c="blue">
Mendeteksi durasi...
</Text>
)}
<ActionIcon <ActionIcon
variant="filled" variant="filled"
color="red" color="red"

View File

@@ -23,8 +23,8 @@ import { IconCircleDashedPlus, IconDeviceImacCog, IconSearch } from '@tabler/ico
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header'; import HeaderSearch from '../_com/header';
import stateDashboardMusik from '../../_state/desa/musik'; import stateDashboardMusik from '../_state/desa/musik';
function Musik() { function Musik() {
@@ -73,7 +73,7 @@ function ListMusik({ search }: { search: string }) {
leftSection={<IconCircleDashedPlus size={18} />} leftSection={<IconCircleDashedPlus size={18} />}
color="blue" color="blue"
variant="light" variant="light"
onClick={() => router.push('/admin/desa/musik/create')} onClick={() => router.push('/admin/musik/create')}
> >
Tambah Baru Tambah Baru
</Button> </Button>
@@ -122,7 +122,7 @@ function ListMusik({ search }: { search: string }) {
variant="light" variant="light"
color="blue" color="blue"
onClick={() => onClick={() =>
router.push(`/admin/desa/musik/${item.id}`) router.push(`/admin/musik/${item.id}`)
} }
fz="sm" fz="sm"
px="sm" px="sm"
@@ -189,7 +189,7 @@ function ListMusik({ search }: { search: string }) {
fullWidth fullWidth
mt="sm" mt="sm"
onClick={() => onClick={() =>
router.push(`/admin/desa/musik/${item.id}`) router.push(`/admin/musik/${item.id}`)
} }
fz="sm" fz="sm"
h={36} h={36}

View File

@@ -373,6 +373,11 @@ export const devBar = [
} }
] ]
}, },
{
id: "Musik",
name: "Musik",
path: "/admin/musik"
},
{ {
id: "User & Role", id: "User & Role",
name: "User & Role", name: "User & Role",
@@ -772,6 +777,11 @@ export const navBar = [
} }
] ]
}, },
{
id: "Musik",
name: "Musik",
path: "/admin/musik"
},
{ {
id: "User & Role", id: "User & Role",
name: "User & Role", name: "User & Role",
@@ -1088,6 +1098,11 @@ export const role1 = [
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana" path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
} }
] ]
},
{
id: "Musik",
name: "Musik",
path: "/admin/musik"
} }
] ]
@@ -1133,6 +1148,11 @@ export const role2 = [
path: "/admin/kesehatan/info-wabah-penyakit" path: "/admin/kesehatan/info-wabah-penyakit"
} }
] ]
},
{
id: "Musik",
name: "Musik",
path: "/admin/musik"
} }
] ]
@@ -1178,5 +1198,10 @@ export const role3 = [
path: "/admin/pendidikan/data-pendidikan" path: "/admin/pendidikan/data-pendidikan"
} }
] ]
},
{
id: "Musik",
name: "Musik",
path: "/admin/musik"
} }
] ]

View File

@@ -316,8 +316,13 @@ export default function Layout({ children }: { children: React.ReactNode }) {
}} }}
variant="light" variant="light"
active={isParentActive} active={isParentActive}
onClick={(e) => {
e.preventDefault();
if (v.path) handleNavClick(v.path);
}}
href={v.path || undefined}
> >
{v.children.map((child, key) => { {v.children?.map((child, key) => {
const isChildActive = segments.includes(_.lowerCase(child.name)); const isChildActive = segments.includes(_.lowerCase(child.name));
return ( return (
<NavLink <NavLink

View File

@@ -22,9 +22,10 @@ const fileStorageCreate = async (context: Context) => {
if (!UPLOAD_DIR) return { status: 500, body: "UPLOAD_DIR is not defined" }; if (!UPLOAD_DIR) return { status: 500, body: "UPLOAD_DIR is not defined" };
const isImage = file.type.startsWith("image/"); const isImage = file.type.startsWith("image/");
const category = isImage ? "image" : "document"; const isAudio = file.type.startsWith("audio/");
const category = isImage ? "image" : isAudio ? "audio" : "document";
const pathName = category === "image" ? "images" : "documents"; const pathName = category === "image" ? "images" : category === "audio" ? "audio" : "documents";
const rootPath = path.join(UPLOAD_DIR, pathName); const rootPath = path.join(UPLOAD_DIR, pathName);
await fs.mkdir(rootPath, { recursive: true }); await fs.mkdir(rootPath, { recursive: true });
@@ -54,6 +55,11 @@ const fileStorageCreate = async (context: Context) => {
// Simpan metadata untuk versi desktop sebagai default // Simpan metadata untuk versi desktop sebagai default
finalName = desktopName; finalName = desktopName;
finalMimeType = "image/webp"; finalMimeType = "image/webp";
} else if (isAudio) {
// Simpan file audio tanpa kompresi
const ext = file.name.split(".").pop() || "mp3";
finalName = `${finalName}.${ext}`;
await fs.writeFile(path.join(rootPath, finalName), buffer);
} else { } else {
// Jika file adalah PDF, simpan tanpa kompresi // Jika file adalah PDF, simpan tanpa kompresi
if (file.type === "application/pdf") { if (file.type === "application/pdf") {

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,25 @@
export function seekTo(
audioRef: React.RefObject<HTMLAudioElement | null>,
time: number
) {
if (!audioRef.current) return;
audioRef.current.currentTime = time;
}
// import { RefObject } from "react";
// export function seekTo(
// audioRef: RefObject<HTMLAudioElement | null>,
// time: number,
// setCurrentTime: (v: number) => void
// ) {
// if (!audioRef.current) return;
// audioRef.current.currentTime = time;
// setCurrentTime(time);
// }
// //pakai di ui
// // onChange={(v) => seekTo(audioRef, v, setCurrentTime)}

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,45 +1,120 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client' 'use client'
import { ActionIcon, Avatar, Badge, Box, Card, Flex, Grid, Group, Paper, Slider, Stack, Text, TextInput } from '@mantine/core'; 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 { IconArrowsShuffle, IconPlayerPauseFilled, IconPlayerPlayFilled, IconPlayerSkipBackFilled, IconPlayerSkipForwardFilled, IconRepeat, IconRepeatOff, IconSearch, IconVolume, IconVolumeOff, IconX } from '@tabler/icons-react';
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import BackButton from '../../desa/layanan/_com/BackButto'; 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;
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 MusicPlayer = () => {
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(245); const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(70); const [volume, setVolume] = useState(70);
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
const [isRepeat, setIsRepeat] = useState(false); const [isRepeat, setIsRepeat] = useState(false);
const [isShuffle, setIsShuffle] = useState(false); const [isShuffle, setIsShuffle] = useState(false);
const [search, setSearch] = useState('');
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 songs = [ // Fetch musik data from API
{ id: 1, title: 'Midnight Dreams', artist: 'The Wanderers', duration: '4:05', cover: 'https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=400&h=400&fit=crop' },
{ id: 2, title: 'Summer Breeze', artist: 'Coastal Vibes', duration: '3:42', cover: 'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=400&h=400&fit=crop' },
{ id: 3, title: 'City Lights', artist: 'Urban Echo', duration: '4:18', cover: 'https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=400&h=400&fit=crop' },
{ id: 4, title: 'Ocean Waves', artist: 'Serenity Sound', duration: '5:20', cover: 'https://images.unsplash.com/photo-1459749411175-04bf5292ceea?w=400&h=400&fit=crop' },
{ id: 5, title: 'Neon Nights', artist: 'Electric Dreams', duration: '3:55', cover: 'https://images.unsplash.com/photo-1487180144351-b8472da7d491?w=400&h=400&fit=crop' },
{ id: 6, title: 'Mountain High', artist: 'Peak Performers', duration: '4:32', cover: 'https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?w=400&h=400&fit=crop' }
];
const [currentSong, setCurrentSong] = useState(songs[0]);
useEffect(() => { useEffect(() => {
let interval: any; 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();
}, []);
// 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()))
);
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
useEffect(() => {
if (currentSong && audioRef.current) {
const durationParts = currentSong.durasi.split(':');
const durationInSeconds = parseInt(durationParts[0]) * 60 + parseInt(durationParts[1]);
setDuration(durationInSeconds);
setCurrentTime(0);
if (isPlaying) { if (isPlaying) {
interval = setInterval(() => { audioRef.current.play().catch(err => {
setCurrentTime(prev => { console.error('Error playing audio:', err);
if (prev >= duration) {
setIsPlaying(false); setIsPlaying(false);
return 0;
}
return prev + 1;
}); });
}, 1000);
} }
return () => clearInterval(interval); }
}, [isPlaying, duration]); }, [currentSongIndex, currentSong, isPlaying]);
const formatTime = (seconds: number) => { const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);
@@ -47,20 +122,98 @@ const MusicPlayer = () => {
return `${mins}:${secs.toString().padStart(2, '0')}`; return `${mins}:${secs.toString().padStart(2, '0')}`;
}; };
const playSong = (song: any) => { const playSong = (index: number) => {
setCurrentSong(song); if (index < 0 || index >= filteredMusik.length) return;
setCurrentTime(0);
setCurrentSongIndex(index);
setIsPlaying(true); setIsPlaying(true);
const durationInSeconds = parseInt(song.duration.split(':')[0]) * 60 + parseInt(song.duration.split(':')[1]); };
setDuration(durationInSeconds);
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 handleSeek = (value: number) => {
seekTo(audioRef, value);
}; };
const toggleMute = () => { const toggleMute = () => {
setIsMuted(!isMuted); 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 togglePlayPauseHandler = () => {
if (!currentSong) return;
togglePlayPause(audioRef, isPlaying, setIsPlaying);
};
if (loading) {
return (
<Box px={{ base: 'md', md: 100 }} py="xl">
<Paper mx="auto" p="xl" radius="lg" shadow="sm" bg="white">
<Text ta="center">Memuat data musik...</Text>
</Paper>
</Box>
);
}
return ( return (
<Box px={{ base: 'md', md: 100 }} py="xl"> <Box px={{ base: 'md', md: 100 }} py="xl">
{/* Hidden audio element */}
{currentSong?.audioFile && (
<audio
ref={audioRef}
src={currentSong.audioFile.link}
muted={isMuted}
onLoadedMetadata={() => {
if (!audioRef.current) return;
setDuration(Math.floor(audioRef.current.duration));
}}
onTimeUpdate={() => {
if (!audioRef.current || isSeeking) return;
setCurrentTime(Math.floor(audioRef.current.currentTime));
}}
onEnded={handleSongEnd}
/>
)}
<Paper <Paper
mx="auto" mx="auto"
p="xl" p="xl"
@@ -84,6 +237,8 @@ const MusicPlayer = () => {
leftSection={<IconSearch size={18} />} leftSection={<IconSearch size={18} />}
radius="xl" radius="xl"
w={280} w={280}
value={search}
onChange={(e) => setSearch(e.target.value)}
styles={{ input: { backgroundColor: '#fff' } }} styles={{ input: { backgroundColor: '#fff' } }}
/> />
</Group> </Group>
@@ -91,20 +246,28 @@ const MusicPlayer = () => {
<Stack gap="xl"> <Stack gap="xl">
<div> <div>
<Text size="xl" fw={700} c="#0B4F78" mb="md">Sedang Diputar</Text> <Text size="xl" fw={700} c="#0B4F78" mb="md">Sedang Diputar</Text>
{currentSong ? (
<Card radius="md" p="xl" shadow="md"> <Card radius="md" p="xl" shadow="md">
<Group align="center" gap="xl"> <Group align="center" gap="xl">
<Avatar src={currentSong.cover} 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 }}> <Stack gap="md" style={{ flex: 1 }}>
<div> <div>
<Text size="28px" fw={700} c="#0B4F78">{currentSong.title}</Text> <Text size="28px" fw={700} c="#0B4F78">{currentSong.judul}</Text>
<Text size="lg" c="#5A6C7D">{currentSong.artist}</Text> <Text size="lg" c="#5A6C7D">{currentSong.artis}</Text>
{currentSong.genre && (
<Badge mt="xs" color="#0B4F78" variant="light">{currentSong.genre}</Badge>
)}
</div> </div>
<Group gap="xs" align="center"> <Group gap="xs" align="center">
<Text size="xs" c="#5A6C7D" w={42}>{formatTime(currentTime)}</Text> <Text size="xs" c="#5A6C7D" w={42}>{formatTime(currentTime)}</Text>
<Slider <Slider
value={currentTime} value={currentTime}
max={duration} max={duration}
onChange={setCurrentTime} onChange={handleSeek}
color="#0B4F78" color="#0B4F78"
size="sm" size="sm"
style={{ flex: 1 }} style={{ flex: 1 }}
@@ -115,12 +278,21 @@ const MusicPlayer = () => {
</Stack> </Stack>
</Group> </Group>
</Card> </Card>
) : (
<Card radius="md" p="xl" shadow="md">
<Text ta="center" c="dimmed">Pilih lagu untuk diputar</Text>
</Card>
)}
</div> </div>
<div> <div>
<Text size="xl" fw={700} c="#0B4F78" mb="md">Daftar Putar</Text> <Text size="xl" fw={700} c="#0B4F78" mb="md">Daftar Putar</Text>
{filteredMusik.length === 0 ? (
<Text ta="center" c="dimmed">Tidak ada musik yang ditemukan</Text>
) : (
<ScrollArea.Autosize mah={400}>
<Grid gutter="md"> <Grid gutter="md">
{songs.map(song => ( {filteredMusik.map((song, index) => (
<Grid.Col span={{ base: 12, sm: 6, lg: 4 }} key={song.id}> <Grid.Col span={{ base: 12, sm: 6, lg: 4 }} key={song.id}>
<Card <Card
radius="md" radius="md"
@@ -128,19 +300,23 @@ const MusicPlayer = () => {
shadow="sm" shadow="sm"
style={{ style={{
cursor: 'pointer', cursor: 'pointer',
border: currentSong.id === song.id ? '2px solid #0B4F78' : '2px solid transparent', border: currentSong?.id === song.id ? '2px solid #0B4F78' : '2px solid transparent',
transition: 'all 0.2s' transition: 'all 0.2s'
}} }}
onClick={() => playSong(song)} onClick={() => playSong(index)}
> >
<Group gap="md" align="center"> <Group gap="md" align="center">
<Avatar src={song.cover} 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 }}> <Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} c="#0B4F78" truncate>{song.title}</Text> <Text size="sm" fw={600} c="#0B4F78" truncate>{song.judul}</Text>
<Text size="xs" c="#5A6C7D">{song.artist}</Text> <Text size="xs" c="#5A6C7D">{song.artis}</Text>
<Text size="xs" c="#8A9BA8">{song.duration}</Text> <Text size="xs" c="#8A9BA8">{song.durasi}</Text>
</Stack> </Stack>
{currentSong.id === song.id && isPlaying && ( {currentSong?.id === song.id && isPlaying && (
<Badge color="#0B4F78" variant="filled">Memutar</Badge> <Badge color="#0B4F78" variant="filled">Memutar</Badge>
)} )}
</Group> </Group>
@@ -148,6 +324,8 @@ const MusicPlayer = () => {
</Grid.Col> </Grid.Col>
))} ))}
</Grid> </Grid>
</ScrollArea.Autosize>
)}
</div> </div>
</Stack> </Stack>
@@ -168,10 +346,20 @@ const MusicPlayer = () => {
> >
<Flex align="center" justify="space-between" gap="xl" h="100%"> <Flex align="center" justify="space-between" gap="xl" h="100%">
<Group gap="md" style={{ flex: 1 }}> <Group gap="md" style={{ flex: 1 }}>
<Avatar src={currentSong.cover} 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 }}> <div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} c="#0B4F78" truncate>{currentSong.title}</Text> {currentSong ? (
<Text size="xs" c="#5A6C7D">{currentSong.artist}</Text> <>
<Text size="sm" fw={600} c="#0B4F78" truncate>{currentSong.judul}</Text>
<Text size="xs" c="#5A6C7D">{currentSong.artis}</Text>
</>
) : (
<Text size="sm" c="dimmed">Tidak ada lagu</Text>
)}
</div> </div>
</Group> </Group>
@@ -180,12 +368,12 @@ const MusicPlayer = () => {
<ActionIcon <ActionIcon
variant={isShuffle ? 'filled' : 'subtle'} variant={isShuffle ? 'filled' : 'subtle'}
color="#0B4F78" color="#0B4F78"
onClick={() => setIsShuffle(!isShuffle)} onClick={toggleShuffleHandler}
radius="xl" radius="xl"
> >
{isShuffle ? <IconArrowsShuffle size={18} /> : <IconX size={18} />} {isShuffle ? <IconArrowsShuffle size={18} /> : <IconX size={18} />}
</ActionIcon> </ActionIcon>
<ActionIcon variant="light" color="#0B4F78" size={40} radius="xl"> <ActionIcon variant="light" color="#0B4F78" size={40} radius="xl" onClick={skipBack}>
<IconPlayerSkipBackFilled size={20} /> <IconPlayerSkipBackFilled size={20} />
</ActionIcon> </ActionIcon>
<ActionIcon <ActionIcon
@@ -193,11 +381,11 @@ const MusicPlayer = () => {
color="#0B4F78" color="#0B4F78"
size={56} size={56}
radius="xl" radius="xl"
onClick={() => setIsPlaying(!isPlaying)} onClick={togglePlayPauseHandler}
> >
{isPlaying ? <IconPlayerPauseFilled size={26} /> : <IconPlayerPlayFilled size={26} />} {isPlaying ? <IconPlayerPauseFilled size={26} /> : <IconPlayerPlayFilled size={26} />}
</ActionIcon> </ActionIcon>
<ActionIcon variant="light" color="#0B4F78" size={40} radius="xl"> <ActionIcon variant="light" color="#0B4F78" size={40} radius="xl" onClick={skipForward}>
<IconPlayerSkipForwardFilled size={20} /> <IconPlayerSkipForwardFilled size={20} />
</ActionIcon> </ActionIcon>
<ActionIcon <ActionIcon
@@ -214,7 +402,14 @@ const MusicPlayer = () => {
<Slider <Slider
value={currentTime} value={currentTime}
max={duration} max={duration}
onChange={setCurrentTime} onChange={(v) => {
setIsSeeking(true);
setCurrentTime(v); // preview
}}
onChangeEnd={(v) => {
seekTo(audioRef, v); // commit
setIsSeeking(false);
}}
color="#0B4F78" color="#0B4F78"
size="xs" size="xs"
style={{ flex: 1 }} style={{ flex: 1 }}
@@ -229,10 +424,7 @@ const MusicPlayer = () => {
</ActionIcon> </ActionIcon>
<Slider <Slider
value={isMuted ? 0 : volume} value={isMuted ? 0 : volume}
onChange={(val) => { onChange={handleVolumeChange}
setVolume(val);
if (val > 0) setIsMuted(false);
}}
color="#0B4F78" color="#0B4F78"
size="xs" size="xs"
w={100} w={100}

View File

@@ -12,6 +12,9 @@ import { Metadata, Viewport } from "next";
import { ViewTransitions } from "next-view-transitions"; import { ViewTransitions } from "next-view-transitions";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
// Force dynamic rendering untuk menghindari error prerendering
export const dynamic = 'force-dynamic';
// ✅ Pisahkan viewport ke export tersendiri // ✅ Pisahkan viewport ke export tersendiri
export const viewport: Viewport = { export const viewport: Viewport = {
width: "device-width", width: "device-width",