Project started 🥂

This commit is contained in:
2026-06-02 01:13:22 +03:00
commit 612d0f0125
146 changed files with 15242 additions and 0 deletions
+5
View File
@@ -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);
+137
View File
@@ -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 };
}
+29
View File
@@ -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;
}
+21
View File
@@ -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 };
}