Project started 🥂
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import type { AppDispatch, RootState } from '../store';
|
||||
|
||||
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||
export const useAppSelector = <T>(selector: (state: RootState) => T) => useSelector(selector);
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from './useAppDispatch';
|
||||
import {
|
||||
pause, resume, setPosition, setDuration,
|
||||
setVolume as setVolumeAction,
|
||||
} from '../store/slices/player';
|
||||
import { nextTrack, prevTrack } from '../store/slices/queue';
|
||||
import { play } from '../store/slices/player';
|
||||
import { getStreamUrl, getCoverUrl } from '../api/endpoints/streaming';
|
||||
|
||||
let audioElement: HTMLAudioElement | null = null;
|
||||
|
||||
function getAudio(): HTMLAudioElement {
|
||||
if (!audioElement) {
|
||||
audioElement = new Audio();
|
||||
audioElement.preload = 'metadata';
|
||||
}
|
||||
return audioElement;
|
||||
}
|
||||
|
||||
export function useAudioPlayer() {
|
||||
const dispatch = useAppDispatch();
|
||||
const player = useAppSelector((s) => s.player);
|
||||
const queue = useAppSelector((s) => s.queue);
|
||||
const accessToken = useAppSelector((s) => s.auth.accessToken);
|
||||
const isSetup = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSetup.current) return;
|
||||
isSetup.current = true;
|
||||
|
||||
const audio = getAudio();
|
||||
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
dispatch(setPosition(audio.currentTime));
|
||||
});
|
||||
audio.addEventListener('durationchange', () => {
|
||||
dispatch(setDuration(audio.duration || 0));
|
||||
});
|
||||
audio.addEventListener('ended', () => {
|
||||
dispatch(nextTrack());
|
||||
});
|
||||
audio.addEventListener('pause', () => {
|
||||
dispatch(pause());
|
||||
});
|
||||
audio.addEventListener('play', () => {
|
||||
dispatch(resume());
|
||||
});
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!player.currentTrackId || !accessToken) return;
|
||||
const audio = getAudio();
|
||||
const url = getStreamUrl(player.currentTrackId, accessToken);
|
||||
if (audio.src !== url) {
|
||||
audio.src = url;
|
||||
audio.load();
|
||||
}
|
||||
if (player.isPlaying) {
|
||||
void audio.play();
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
}, [player.currentTrackId, player.isPlaying, accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = getAudio();
|
||||
audio.volume = player.muted ? 0 : player.volume;
|
||||
}, [player.volume, player.muted]);
|
||||
|
||||
// MediaSession: system media controls + metadata (lock screen, OS, headset keys)
|
||||
useEffect(() => {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
const entry = queue.entries[queue.currentIndex];
|
||||
if (!entry) {
|
||||
navigator.mediaSession.metadata = null;
|
||||
return;
|
||||
}
|
||||
const artUrl = getCoverUrl(entry.albumArtUrl);
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: entry.title,
|
||||
artist: entry.artistName,
|
||||
album: entry.albumTitle,
|
||||
artwork: artUrl ? [{ src: artUrl }] : [],
|
||||
});
|
||||
}, [queue.entries, queue.currentIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
navigator.mediaSession.playbackState = player.isPlaying ? 'playing' : 'paused';
|
||||
}, [player.isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
const ms = navigator.mediaSession;
|
||||
ms.setActionHandler('play', () => dispatch(resume()));
|
||||
ms.setActionHandler('pause', () => dispatch(pause()));
|
||||
ms.setActionHandler('previoustrack', () => dispatch(prevTrack()));
|
||||
ms.setActionHandler('nexttrack', () => dispatch(nextTrack()));
|
||||
ms.setActionHandler('seekto', (details) => {
|
||||
if (typeof details.seekTime === 'number') {
|
||||
getAudio().currentTime = details.seekTime;
|
||||
dispatch(setPosition(details.seekTime));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
ms.setActionHandler('play', null);
|
||||
ms.setActionHandler('pause', null);
|
||||
ms.setActionHandler('previoustrack', null);
|
||||
ms.setActionHandler('nexttrack', null);
|
||||
ms.setActionHandler('seekto', null);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentEntry = queue.entries[queue.currentIndex];
|
||||
if (!currentEntry) return;
|
||||
if (currentEntry.trackId !== player.currentTrackId) {
|
||||
dispatch(play(currentEntry.trackId));
|
||||
}
|
||||
}, [queue.currentIndex, queue.entries, player.currentTrackId, dispatch]);
|
||||
|
||||
const seek = useCallback((seconds: number) => {
|
||||
const audio = getAudio();
|
||||
audio.currentTime = seconds;
|
||||
dispatch(setPosition(seconds));
|
||||
}, [dispatch]);
|
||||
|
||||
const setPlayerVolume = useCallback((vol: number) => {
|
||||
dispatch(setVolumeAction(vol));
|
||||
}, [dispatch]);
|
||||
|
||||
const playNext = useCallback(() => { dispatch(nextTrack()); }, [dispatch]);
|
||||
const playPrev = useCallback(() => { dispatch(prevTrack()); }, [dispatch]);
|
||||
|
||||
return { seek, setVolume: setPlayerVolume, playNext, playPrev };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getApiBaseUrl } from '../config/runtime-config';
|
||||
|
||||
type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error';
|
||||
|
||||
export function useConnectionStatus() {
|
||||
const [status, setStatus] = useState<ConnectionStatus>('connecting');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const check = async () => {
|
||||
if (cancelled) return;
|
||||
setStatus('connecting');
|
||||
try {
|
||||
const res = await fetch(`${getApiBaseUrl()}/health`, { signal: AbortSignal.timeout(5000) });
|
||||
if (!cancelled) setStatus(res.ok ? 'connected' : 'error');
|
||||
} catch {
|
||||
if (!cancelled) setStatus('disconnected');
|
||||
}
|
||||
};
|
||||
|
||||
void check();
|
||||
const interval = setInterval(() => { void check(); }, 30_000);
|
||||
return () => { cancelled = true; clearInterval(interval); };
|
||||
}, []);
|
||||
|
||||
return status;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useAppSelector } from './useAppDispatch';
|
||||
|
||||
type Permission = 'download' | 'upload' | 'admin' | 'manage_users' | 'edit_metadata' | 'delete_tracks';
|
||||
|
||||
const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
||||
admin: ['download', 'upload', 'admin', 'manage_users', 'edit_metadata', 'delete_tracks'],
|
||||
user: ['download', 'upload'],
|
||||
};
|
||||
|
||||
export function usePermissions() {
|
||||
const user = useAppSelector((s) => s.auth.user);
|
||||
|
||||
const hasPermission = (permission: Permission): boolean => {
|
||||
if (!user) return false;
|
||||
return ROLE_PERMISSIONS[user.role]?.includes(permission) ?? false;
|
||||
};
|
||||
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
return { hasPermission, isAdmin, user };
|
||||
}
|
||||
Reference in New Issue
Block a user