diff --git a/src/components/AudioPlayer/AudioPlayerControls.tsx b/src/components/AudioPlayer/AudioPlayerControls.tsx new file mode 100644 index 0000000..3b68bdd --- /dev/null +++ b/src/components/AudioPlayer/AudioPlayerControls.tsx @@ -0,0 +1,274 @@ +import React, { + forwardRef, + useImperativeHandle, + useRef, + useState, + useEffect, + useCallback, + } from 'react'; + import { Box } from '@mui/material'; +import { QortalGetMetadata } from '../../types/interfaces/resources'; +import { useResourceStatus } from '../../hooks/useResourceStatus'; + + export interface OnTrackChangeMeta { + hasNext: boolean; + hasPrevious: boolean; + } + + export interface AudioPlayerProps { + srcs: QortalGetMetadata[]; + currentTrack?: QortalGetMetadata; + controls?: boolean; + style?: React.CSSProperties; + className?: string; + sx?: object; + loopCurrentTrack?: boolean; + shuffle?: boolean; + onTrackChange?: (track: QortalGetMetadata, meta: OnTrackChangeMeta) => void; + onEndedAll?: () => void; + onPlay?: () => void; + onPause?: () => void; + onEnded?: () => void; + onError?: React.ReactEventHandler; + onProgress?: (currentTime: number, duration: number) => void; + onResourceStatus?: ( + resourceStatus: ReturnType + ) => void; + } + + export interface AudioPlayerHandle { + play: () => void; + pause: () => void; + stop: () => void; + next: () => void; + prev: () => void; + setTrack: (track: QortalGetMetadata) => void; + seekTo: (seconds: number) => void; + setVolume: (level: number) => void; + setMuted: (muted: boolean) => void; + toggleMute: () => void; + isPlaying: boolean; + currentTrackIndex: number; + audioEl: HTMLAudioElement | null; + } + + const AudioPlayerComponent = forwardRef( + ( + { + srcs, + currentTrack, + style, + className, + sx, + loopCurrentTrack = false, + shuffle = false, + onTrackChange, + onEndedAll, + onPlay, + onPause, + onEnded, + onError, + onProgress, + onResourceStatus, + }, + ref + ) => { + const audioRef = useRef(null); + const [shuffledOrder, setShuffledOrder] = useState([]); + const [shuffledIndex, setShuffledIndex] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + + const isControlled = currentTrack !== undefined; + const [activeTrack, setActiveTrack] = useState( + currentTrack || srcs[0] + ); + + useEffect(() => { + if (isControlled && currentTrack) { + setActiveTrack(currentTrack); + } + }, [currentTrack, isControlled]); + + const resetShuffle = useCallback(() => { + setShuffledOrder([]); + setShuffledIndex(0); + }, []); + + useEffect(() => { + resetShuffle(); + }, [shuffle, resetShuffle, srcs]); + + useEffect(() => { + if (shuffle) { + const indices = srcs.map((_, i) => i); + for (let i = indices.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [indices[i], indices[j]] = [indices[j], indices[i]]; + } + setShuffledOrder(indices); + setShuffledIndex(0); + setActiveTrack(srcs[indices[0]]); + } + }, [shuffle, srcs]); + + const trackIndex = srcs.findIndex( + (t) => + t.identifier === activeTrack?.identifier && + t.service === activeTrack?.service && + t.name === activeTrack?.name + ); + const resourceStatus = useResourceStatus({ + resource: activeTrack || null, + }); + const { isReady, resourceUrl } = resourceStatus; + const hasNext = trackIndex < srcs.length - 1; + const hasPrevious = trackIndex > 0; + + const setTrack = (track: QortalGetMetadata) => { + setActiveTrack(track); + }; + + const play = () => audioRef.current?.play(); + const pause = () => audioRef.current?.pause(); + const stop = () => { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.currentTime = 0; + setIsPlaying(false); + } + }; + + const next = () => { + if (shuffle) { + const nextIndex = shuffledIndex + 1; + if (nextIndex < shuffledOrder.length) { + setShuffledIndex(nextIndex); + setTrack(srcs[shuffledOrder[nextIndex]]); + } else { + onEndedAll?.(); + } + } else if (hasNext) { + setTrack(srcs[trackIndex + 1]); + } else { + onEndedAll?.(); + } + }; + + const prev = () => { + if (shuffle) { + const prevIndex = shuffledIndex - 1; + if (prevIndex >= 0) { + setShuffledIndex(prevIndex); + setTrack(srcs[shuffledOrder[prevIndex]]); + } + } else if (hasPrevious) { + setTrack(srcs[trackIndex - 1]); + } + }; + + const seekTo = (seconds: number) => { + if (audioRef.current) { + audioRef.current.currentTime = seconds; + } + }; + + const setVolume = (level: number) => { + if (audioRef.current) { + audioRef.current.volume = Math.min(1, Math.max(0, level)); + } + }; + + const setMuted = (muted: boolean) => { + if (audioRef.current) { + audioRef.current.muted = muted; + } + }; + + const toggleMute = () => { + if (audioRef.current) { + audioRef.current.muted = !audioRef.current.muted; + } + }; + + useEffect(() => { + if (audioRef.current && isReady && resourceUrl) { + audioRef.current.pause(); + audioRef.current.src = resourceUrl; + + audioRef.current.currentTime = 0; + audioRef.current.play(); + } + }, [resourceUrl, isReady]); + + useEffect(() => { + const index = srcs.findIndex( + (t) => + t.identifier === activeTrack?.identifier && + t.service === activeTrack?.service && + t.name === activeTrack?.name + ); + console.log('srcs2', srcs, activeTrack, index); + if (index !== -1) { + onTrackChange?.(activeTrack, { + hasNext: index < srcs.length - 1, + hasPrevious: index > 0, + }); + } + }, [activeTrack, srcs, onTrackChange]); + + useEffect(() => { + if (onResourceStatus) { + onResourceStatus(resourceStatus); + } + }, [onResourceStatus, resourceStatus]); + + useImperativeHandle(ref, () => ({ + play, + pause, + stop, + next, + prev, + setTrack, + seekTo, + setVolume, + setMuted, + toggleMute, + isPlaying, + currentTrackIndex: trackIndex, + audioEl: audioRef.current, + })); + + return ( + + + ); + } + ); + + export const AudioPlayerControls = React.memo(AudioPlayerComponent); + \ No newline at end of file diff --git a/src/components/AudioPlayer/useAudioPlayerHotkeys.tsx b/src/components/AudioPlayer/useAudioPlayerHotkeys.tsx new file mode 100644 index 0000000..359fc55 --- /dev/null +++ b/src/components/AudioPlayer/useAudioPlayerHotkeys.tsx @@ -0,0 +1,54 @@ +import { useEffect } from 'react'; +import { AudioPlayerHandle } from './AudioPlayerControls'; + +export const useAudioPlayerHotkeys = ( + ref: React.RefObject, + isAudioPlayerAvalable: boolean +) => { + useEffect(() => { + if (!ref?.current || !isAudioPlayerAvalable) return; + const handleKeyDown = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement)?.tagName; + const isTyping = + tag === 'INPUT' || + tag === 'TEXTAREA' || + (e.target as HTMLElement)?.isContentEditable; + if (isTyping) return; + + const audio = ref.current; + + switch (e.key) { + case ' ': + e.preventDefault(); + if (audio?.isPlaying) { + audio.pause(); + } else { + audio?.play(); + } + + break; + case 'ArrowLeft': + audio?.seekTo((audio.audioEl?.currentTime || 0) - 5); + break; + case 'ArrowRight': + audio?.seekTo((audio.audioEl?.currentTime || 0) + 5); + break; + case 'm': + case 'M': + audio?.toggleMute(); + break; + case 'n': + case 'N': + audio?.next(); + break; + case 'p': + case 'P': + audio?.prev(); + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [ref, isAudioPlayerAvalable]); +}; diff --git a/src/hooks/usePublish.tsx b/src/hooks/usePublish.tsx index 5f8c037..6aa6980 100644 --- a/src/hooks/usePublish.tsx +++ b/src/hooks/usePublish.tsx @@ -11,11 +11,54 @@ interface StoredPublish { data: any; timestamp: number; } -export const usePublish = ( - maxFetchTries: number = 3, - returnType: ReturnType = "JSON", - metadata?: QortalGetMetadata -) => { + + type UsePublishWithMetadata = { + isLoading: boolean; + error: string | null; + resource: { qortalMetadata: QortalMetadata; data: any } | null; + hasResource: boolean | null; + refetch: () => Promise<{ + hasResource: boolean | null; + resource: { qortalMetadata: QortalMetadata; data: any } | null; + error: string | null; + }> + fetchPublish: (metadataProp: QortalGetMetadata) => Promise<{ + hasResource: boolean | null; + resource: { qortalMetadata: QortalMetadata; data: any } | null; + error: string | null; + }>; + updatePublish: (publish: QortalGetMetadata, data: any) => Promise; + deletePublish: (publish: QortalGetMetadata) => Promise; + }; + + type UsePublishWithoutMetadata = { + fetchPublish: (metadataProp: QortalGetMetadata) => Promise<{ + hasResource: boolean | null; + resource: { qortalMetadata: QortalMetadata; data: any } | null; + error: string | null; + }>; + updatePublish: (publish: QortalGetMetadata, data: any) => Promise; + deletePublish: (publish: QortalGetMetadata) => Promise; + }; + + export function usePublish( + maxFetchTries: number, + returnType: ReturnType, + metadata: QortalGetMetadata + ): UsePublishWithMetadata; + + export function usePublish( + maxFetchTries?: number, + returnType?: ReturnType, + metadata?: null + ): UsePublishWithoutMetadata; + + // ✅ Actual implementation (must be a `function`, not `const`) + export function usePublish( + maxFetchTries: number = 3, + returnType: ReturnType = "JSON", + metadata?: QortalGetMetadata | null + ): UsePublishWithMetadata | UsePublishWithoutMetadata { const {auth, appInfo} = useGlobal() const username = auth?.name const appNameHashed = appInfo?.appNameHashed diff --git a/src/index.ts b/src/index.ts index 2d38579..a18ac1d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,8 @@ export { useResourceStatus } from './hooks/useResourceStatus'; export { Spacer } from './common/Spacer'; export { useModal } from './hooks/useModal'; +export { AudioPlayerControls , OnTrackChangeMeta, AudioPlayerProps} from './components/AudioPlayer/AudioPlayerControls'; +export { useAudioPlayerHotkeys } from './components/AudioPlayer/useAudioPlayerHotkeys'; import './index.css' export { formatBytes, formatDuration } from './utils/numbers'; export { createQortalLink } from './utils/qortal';