Features
- Multi-Format Support: Audio (MP3, WAV, OGG), Video (MP4, WebM), Images (JPG, PNG, GIF)
- Audio Visualizer: Real-time frequency spectrum analyzer
- Playlist Management: Multiple folders, drag-and-drop, reordering
- Drag & Drop: Drop media files directly into player
- Volume Control: Independent volume slider with global volume integration
- Playback Controls: Play, Pause, Stop, Previous, Next, Loop
- Folder Organization: Organize media into custom folders
- Scrolling Title: Animated ticker for long track names
- Responsive Design: Adapts to window size
Component Structure
Location:src/WinXP/apps/MediaPlayer/index.jsx
export default function MediaPlayer() {
const [library, setLibrary] = useState(() => {
const lib = { ...mediaLibrary };
delete lib['All Tracks'];
return lib;
});
const [currentFolder, setCurrentFolder] = useState('My Music');
const [currentIndex, setCurrentIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [showPlaylist, setShowPlaylist] = useState(true);
const [isLooping, setIsLooping] = useState(false);
const { volume: globalVolume, isMuted, toggleMute } = useVolume();
// Media playback, visualizer, playlist logic
}
Configuration
App Settings
Fromapps/index.jsx:
MediaPlayer: {
name: 'MediaPlayer',
header: { icon: mediaPlayerIcon, title: 'Media Player', invisible: false },
component: MediaPlayer,
defaultSize: { width: 800, height: 575 },
defaultOffset: getCenter(300, 450),
resizable: true,
minimized: false,
maximized: shouldMaximize(300, 450, true),
multiInstance: false,
minWidth: 370,
minHeight: 370,
}
Media Library
Fromconfig.js:
const BASE_PATH = import.meta.env.BASE_URL.replace(/\/$/, '');
const createMedia = (type, relativePath, title, artist = '', duration = 0) => ({
url: `${BASE_PATH}${relativePath}`,
type,
title,
artist,
duration,
id: Math.random().toString(36).substr(2, 9),
});
const musicTracks = [
createMedia('audio', '/music/addiction.wav', 'Addiction', 'Jogeir Liljedahl', 288),
createMedia('audio', '/music/youwillknow.mp3', 'You Will Know Our Names', 'Kenji Hiramatsu', 161),
createMedia('audio', '/music/music1.wav', 'Music 1', 'Skillz Productions', 118),
createMedia('audio', '/music/man.ogg', 'man', 'Toby Fox', 11),
createMedia('audio', '/music/robocop.mp3', 'robocop.mp3', 'Kombat Unit', 118),
];
const videoTracks = [
// createMedia('video', '/videos/demo.mp4', 'My Cool Video'),
];
const imageTracks = [
// createMedia('image', '/photos/wallpaper.jpg', 'Cool Wallpaper'),
];
export const mediaLibrary = {
'My Music': musicTracks,
Videos: videoTracks,
Photos: imageTracks,
'All Tracks': [...musicTracks, ...videoTracks, ...imageTracks],
};
Media Type Detection
const getMediaType = (url, type) => {
if (type) {
if (type.startsWith('video')) return 'video';
if (type.startsWith('image')) return 'image';
return 'audio';
}
if (!url || typeof url !== 'string') return 'audio';
if (url.match(/\.(mp4|webm|ogv|mkv)$/i)) return 'video';
if (url.match(/\.(jpg|jpeg|png|gif|bmp)$/i)) return 'image';
return 'audio';
};
Audio Visualizer
The visualizer uses Web Audio API to create a frequency spectrum display:const setupVisualizer = useCallback(() => {
if (!mediaRef.current || currentItem.type !== 'audio') return;
try {
if (!audioContextRef.current) {
const AudioContext = window.AudioContext || window.webkitAudioContext;
audioContextRef.current = new AudioContext();
}
const ctx = audioContextRef.current;
if (ctx.state === 'suspended') ctx.resume();
if (!analyserRef.current) {
analyserRef.current = ctx.createAnalyser();
analyserRef.current.fftSize = 2048;
}
if (connectedElementRef.current !== mediaRef.current) {
if (sourceRef.current) {
sourceRef.current.disconnect();
sourceRef.current = null;
}
sourceRef.current = ctx.createMediaElementSource(mediaRef.current);
sourceRef.current.connect(analyserRef.current);
analyserRef.current.connect(ctx.destination);
connectedElementRef.current = mediaRef.current;
}
drawSpectrum();
} catch (e) {}
}, [currentItem.type, drawSpectrum]);
Spectrum Drawing
const drawSpectrum = useCallback(() => {
if (!canvasRef.current || !analyserRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const analyser = analyserRef.current;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const draw = () => {
animationRef.current = requestAnimationFrame(draw);
analyser.getByteFrequencyData(dataArray);
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const barWidth = canvas.width / binsToDraw;
let x = 0;
for (let i = 0; i < binsToDraw; i++) {
const barHeight = (dataArray[i] / 255) * canvas.height;
const gradient = ctx.createLinearGradient(0, canvas.height, 0, 0);
gradient.addColorStop(0, '#004e92');
gradient.addColorStop(1, '#50cc7f');
ctx.fillStyle = gradient;
ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight);
x += barWidth;
}
};
draw();
}, []);
Drag & Drop Support
const handleDrop = e => {
e.preventDefault();
e.stopPropagation();
const files = Array.from(e.dataTransfer.files).filter(
f => f.type.startsWith('audio/') ||
f.type.startsWith('video/') ||
f.type.startsWith('image/')
);
if (files.length > 0) {
const newItems = files.map(file => ({
url: URL.createObjectURL(file),
type: getMediaType(null, file.type),
title: file.name,
artist: '',
id: Math.random().toString(36).substr(2, 9),
}));
if (currentFolder === 'All Media') {
setUncategorized(prev => [...prev, ...newItems]);
} else {
setLibrary(prev => ({
...prev,
[currentFolder]: [...(prev[currentFolder] || []), ...newItems],
}));
}
}
};
<PlayerContainer
onDragOver={e => e.preventDefault()}
onDrop={handleDrop}
>
{/* Player content */}
</PlayerContainer>
Playlist Reordering
Drag and drop to reorder tracks within folders:const dragItem = useRef(null);
const dragOverItem = useRef(null);
const handleSortStart = (e, position) => {
dragItem.current = position;
e.dataTransfer.effectAllowed = 'move';
};
const handleSortEnter = (e, position) => {
dragOverItem.current = position;
};
const handleSortEnd = () => {
if (dragItem.current === null || dragOverItem.current === null) return;
if (currentFolder === 'All Media') return; // Can't reorder in All Media
const newPlaylist = [...library[currentFolder]];
const draggedItemContent = newPlaylist.splice(dragItem.current, 1)[0];
newPlaylist.splice(dragOverItem.current, 0, draggedItemContent);
// Update current index if needed
if (dragItem.current === currentIndex) {
setCurrentIndex(dragOverItem.current);
} else if (dragItem.current < currentIndex && dragOverItem.current >= currentIndex) {
setCurrentIndex(currentIndex - 1);
} else if (dragItem.current > currentIndex && dragOverItem.current <= currentIndex) {
setCurrentIndex(currentIndex + 1);
}
dragItem.current = null;
dragOverItem.current = null;
setLibrary(prev => ({ ...prev, [currentFolder]: newPlaylist }));
};
<PlaylistItem
draggable={currentFolder !== 'All Media'}
onDragStart={e => handleSortStart(e, i)}
onDragEnter={e => handleSortEnter(e, i)}
onDragEnd={handleSortEnd}
onDragOver={e => e.preventDefault()}
>
{/* Item content */}
</PlaylistItem>
Scrolling Title Ticker
For long track names, the title scrolls automatically:const TrackTicker = ({ text }) => {
const [isOverflowing, setIsOverflowing] = useState(false);
const containerRef = useRef(null);
const measurerRef = useRef(null);
useLayoutEffect(() => {
if (containerRef.current && measurerRef.current) {
const containerWidth = containerRef.current.offsetWidth;
const contentWidth = measurerRef.current.offsetWidth;
const shouldScroll = contentWidth > containerWidth;
setIsOverflowing(prev => (prev !== shouldScroll ? shouldScroll : prev));
}
});
const duration = Math.max(5, text.length * 0.25);
return (
<TickerContainer ref={containerRef}>
<span ref={measurerRef} style={{ position: 'absolute', visibility: 'hidden' }}>
{text}
</span>
{isOverflowing ? (
<TickerWrapper duration={duration}>
<TickerItem>{text}</TickerItem>
<TickerItem>{text}</TickerItem>
</TickerWrapper>
) : (
<span>{text}</span>
)}
</TickerContainer>
);
};
const scroll = keyframes`
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
`;
const TickerWrapper = styled.div`
display: inline-flex;
animation: ${scroll} ${props => props.duration}s linear infinite;
`;
Volume Control
Integrates with global volume system:const { volume: globalVolume, isMuted, toggleMute } = useVolume();
const [localVolume, setLocalVolume] = useState(1);
useEffect(() => {
if (mediaRef.current && !isImage) {
mediaRef.current.volume = (globalVolume / 100) * localVolume;
mediaRef.current.muted = isMuted;
}
}, [globalVolume, localVolume, isMuted, isImage, currentItem.id]);
<VolumeContainer>
<MuteBtn onClick={toggleMute}>
<SpeakerIcon muted={isMuted || localVolume === 0} />
</MuteBtn>
<VolumeSlider
type="range"
min={0}
max={1}
step={0.05}
value={localVolume}
onChange={e => setLocalVolume(parseFloat(e.target.value))}
/>
</VolumeContainer>
Playback Controls
const togglePlay = async () => {
if (isImage || !mediaRef.current) return;
if (audioContextRef.current?.state === 'suspended') {
await audioContextRef.current.resume();
}
if (mediaRef.current.paused) {
mediaRef.current.play();
setIsPlaying(true);
setupVisualizer();
} else {
mediaRef.current.pause();
setIsPlaying(false);
if (animationRef.current) cancelAnimationFrame(animationRef.current);
}
};
const stop = () => {
if (isImage || !mediaRef.current) return;
mediaRef.current.pause();
mediaRef.current.currentTime = 0;
setIsPlaying(false);
if (animationRef.current) cancelAnimationFrame(animationRef.current);
};
const nextTrack = () =>
playlist.length && setCurrentIndex(p => (p + 1) % playlist.length);
const prevTrack = () =>
playlist.length && setCurrentIndex(p => (p - 1 + playlist.length) % playlist.length);
const handleEnded = () => {
if (isLooping && mediaRef.current) {
mediaRef.current.currentTime = 0;
mediaRef.current.play();
} else {
nextTrack();
}
};
Media Rendering
const renderMedia = () => {
if (!currentItem.url) return <VisualizerContainer />;
const commonProps = {
key: currentItem.id || currentItem.url,
ref: mediaRef,
onTimeUpdate: () =>
mediaRef.current &&
(setCurrentTime(mediaRef.current.currentTime),
setDuration(mediaRef.current.duration || 0)),
onEnded: handleEnded,
onPlay: () => {
setIsPlaying(true);
setupVisualizer();
},
onPause: () => setIsPlaying(false),
src: currentItem.url,
crossOrigin: 'anonymous',
};
if (currentItem.type === 'video')
return <VideoElement {...commonProps} onClick={togglePlay} />;
if (currentItem.type === 'image')
return <ImageElement src={currentItem.url} />;
return (
<VisualizerContainer>
<audio {...commonProps} />
<canvas
ref={canvasRef}
width={windowSize.width}
height={Math.max(100, windowSize.height - (showPlaylist ? 180 : 80))}
/>
</VisualizerContainer>
);
};
Folder Management
const changeFolder = folderName => {
if (folderName === currentFolder) return;
setIsPlaying(false);
setCurrentIndex(0);
setCurrentTime(0);
setCurrentFolder(folderName);
};
<FolderBar>
{Object.keys(library).map(folder => (
<FolderTab
key={folder}
active={folder === currentFolder}
onClick={() => changeFolder(folder)}
>
{folder}
</FolderTab>
))}
<FolderTab
active={currentFolder === 'All Media'}
onClick={() => changeFolder('All Media')}
>
All Media
</FolderTab>
</FolderBar>
Usage Example
import { MediaPlayer } from 'src/WinXP/apps';
function Desktop() {
return (
<Window title="Media Player">
<MediaPlayer />
</Window>
);
}
Responsive Features
The player adapts to window size:const [windowSize, setWindowSize] = useState({ width: 300, height: 300 });
useEffect(() => {
if (!containerRef.current) return;
resizeObserverRef.current = new ResizeObserver(entries => {
for (let entry of entries) {
setWindowSize({
width: Math.floor(entry.contentRect.width),
height: Math.floor(entry.contentRect.height),
});
}
});
resizeObserverRef.current.observe(containerRef.current);
return () => resizeObserverRef.current?.disconnect();
}, []);
const compactMode = windowSize.width < 350;
<ButtonRow compact={compactMode}>
{/* Controls adjust spacing based on window size */}
</ButtonRow>
