mirror of
https://git.v0l.io/florian/bouquet.git
synced 2025-03-17 21:53:01 +01:00
feat: Added audio player
This commit is contained in:
parent
18bf85e5ec
commit
fbde04cc5e
BIN
public/music-placeholder.png
Normal file
BIN
public/music-placeholder.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
58
src/GlobalState.tsx
Normal file
58
src/GlobalState.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import React, { createContext, useReducer, useContext, ReactNode } from 'react';
|
||||
import { ID3Tag } from './utils/id3';
|
||||
|
||||
|
||||
type Song = {
|
||||
url: string;
|
||||
id3?: ID3Tag;
|
||||
}
|
||||
|
||||
interface State {
|
||||
currentSong?: Song;
|
||||
songs: Song[];
|
||||
}
|
||||
|
||||
const initialState: State = {
|
||||
currentSong: undefined,
|
||||
songs: [],
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: 'SET_CURRENT_SONG'; song: Song }
|
||||
| { type: 'SHUFFLE_SONGS' }
|
||||
| { type: 'ADD_SONG'; song: Song };
|
||||
|
||||
const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case 'SET_CURRENT_SONG':
|
||||
return { ...state, currentSong: action.song };
|
||||
case 'SHUFFLE_SONGS':
|
||||
return { ...state, songs: [...state.songs].sort(() => Math.random() - 0.5) };
|
||||
case 'ADD_SONG':
|
||||
return { ...state, songs: [...state.songs, action.song] };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const GlobalContext = createContext<{ state: State; dispatch: React.Dispatch<Action> } | undefined>(undefined);
|
||||
|
||||
interface GlobalProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const GlobalProvider: React.FC<GlobalProviderProps> = ({ children }) => {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
return <GlobalContext.Provider value={{ state, dispatch }}>{children}</GlobalContext.Provider>;
|
||||
};
|
||||
|
||||
const useGlobalContext = () => {
|
||||
const context = useContext(GlobalContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useGlobalContext must be used within a GlobalProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export { GlobalProvider, useGlobalContext };
|
136
src/components/AudioPlayer.tsx
Normal file
136
src/components/AudioPlayer.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
import { useGlobalContext } from '../GlobalState';
|
||||
import { PauseIcon, PlayIcon, SpeakerWaveIcon, SpeakerXMarkIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
const AudioPlayer: React.FC = () => {
|
||||
const { state } = useGlobalContext();
|
||||
const { currentSong } = state;
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [volumeBeforeMute, setVolumeBeforeMute] = useState(1);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (audio) {
|
||||
const updateProgress = () => {
|
||||
if (audio.duration && !isNaN(audio.currentTime)) {
|
||||
setProgress(((audio.currentTime || 0) / audio.duration) * 100);
|
||||
}
|
||||
};
|
||||
audio.addEventListener('timeupdate', updateProgress);
|
||||
return () => {
|
||||
audio.removeEventListener('timeupdate', updateProgress);
|
||||
};
|
||||
}
|
||||
}, [currentSong]);
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current && currentSong) {
|
||||
audioRef.current.src = currentSong.url;
|
||||
audioRef.current.play();
|
||||
setIsPlaying(true);
|
||||
}
|
||||
}, [currentSong]);
|
||||
|
||||
const playPause = () => {
|
||||
if (isPlaying) {
|
||||
audioRef.current?.pause();
|
||||
} else {
|
||||
audioRef.current?.play();
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
};
|
||||
|
||||
const tuneVolume = (newVolume: number) => {
|
||||
setVolume(newVolume);
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = newVolume;
|
||||
}
|
||||
};
|
||||
|
||||
const changeVolume = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newVolume = parseFloat(event.target.value);
|
||||
tuneVolume(newVolume);
|
||||
};
|
||||
|
||||
return (
|
||||
currentSong && (
|
||||
<div className="audio-player flex items-center space-x-4">
|
||||
<audio ref={audioRef} />
|
||||
{/*currentSong && <span className="font-semibold">{currentSong}</span>
|
||||
*/}
|
||||
<button className="btn btn-icon" onClick={playPause}>
|
||||
{isPlaying ? <PauseIcon className="h-6 w-6" /> : <PlayIcon className="h-6 w-6" />}
|
||||
</button>
|
||||
|
||||
<span className="w-10 hidden md:block">
|
||||
{' '}
|
||||
{dayjs.duration(audioRef.current?.currentTime || 0, 'seconds').format('m:ss')}
|
||||
</span>
|
||||
<div className="flex-grow w-60 hidden md:block cursor-pointer">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={progress}
|
||||
onChange={e =>
|
||||
audioRef.current &&
|
||||
(audioRef.current.currentTime = (parseInt(e.target.value) / 100) * audioRef.current.duration)
|
||||
}
|
||||
className="progress progress-primary w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 cursor-pointer">
|
||||
{volume == 0 ? (
|
||||
<SpeakerXMarkIcon
|
||||
className="h-6 w-6 text-gray-500"
|
||||
onClick={() => {
|
||||
tuneVolume(volumeBeforeMute);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SpeakerWaveIcon
|
||||
className="h-6 w-6 text-gray-500"
|
||||
onClick={() => {
|
||||
setVolumeBeforeMute(volume);
|
||||
tuneVolume(0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={changeVolume}
|
||||
className="progress progress-primary"
|
||||
/>
|
||||
</div>
|
||||
{currentSong.id3 && (
|
||||
<>
|
||||
<div>
|
||||
<img className="w-12 h-12" src={currentSong.id3?.cover}></img>
|
||||
</div>
|
||||
<div className="flex flex-col text-sm">
|
||||
<div className="text-white">{currentSong?.id3.title}</div>
|
||||
<div>{currentSong?.id3.artist}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioPlayer;
|
@ -17,3 +17,16 @@
|
||||
.blog-list-header svg {
|
||||
@apply w-6 opacity-80 hover:opacity-100;
|
||||
}
|
||||
|
||||
.blob-list .cover-image {
|
||||
@apply min-h-[96px] min-w-[96px];
|
||||
}
|
||||
|
||||
.blob-list .cover-image:hover .play-icon {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.blob-list .cover-image .play-icon {
|
||||
@apply opacity-0 absolute text-white top-8 left-8 w-16 h-16 rounded-full bg-[rgba(0,0,0,.4)] p-2 cursor-pointer;
|
||||
}
|
||||
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
ListBulletIcon,
|
||||
MusicalNoteIcon,
|
||||
PhotoIcon,
|
||||
PlayIcon,
|
||||
TrashIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { BlobDescriptor } from 'blossom-client-sdk';
|
||||
@ -13,14 +14,14 @@ import { formatDate, formatFileSize } from '../../utils/utils';
|
||||
import './BlobList.css';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Document, Page } from 'react-pdf';
|
||||
import * as id3 from 'id3js';
|
||||
import { ID3Tag, ID3TagV2 } from 'id3js/lib/id3Tag';
|
||||
import { useQueries } from '@tanstack/react-query';
|
||||
import { useServerInfo } from '../../utils/useServerInfo';
|
||||
import useFileMetaEventsByHash, { KIND_BLOSSOM_DRIVE, KIND_FILE_META } from '../../utils/useFileMetaEvents';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { AddressPointer, EventPointer } from 'nostr-tools/nip19';
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { useGlobalContext } from '../../GlobalState';
|
||||
import { fetchId3Tag } from '../../utils/id3';
|
||||
|
||||
type ListMode = 'gallery' | 'list' | 'audio' | 'video' | 'docs';
|
||||
|
||||
@ -31,12 +32,11 @@ type BlobListProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type AudioBlob = BlobDescriptor & { id3?: ID3Tag; imageData?: string };
|
||||
|
||||
const BlobList = ({ blobs, onDelete, title, className = '' }: BlobListProps) => {
|
||||
const [mode, setMode] = useState<ListMode>('list');
|
||||
const { distribution } = useServerInfo();
|
||||
const fileMetaEventsByHash = useFileMetaEventsByHash();
|
||||
const { dispatch } = useGlobalContext();
|
||||
|
||||
const images = useMemo(
|
||||
() => blobs.filter(b => b.type?.startsWith('image/')).sort((a, b) => (a.uploaded > b.uploaded ? -1 : 1)), // descending
|
||||
@ -48,29 +48,11 @@ const BlobList = ({ blobs, onDelete, title, className = '' }: BlobListProps) =>
|
||||
[blobs]
|
||||
);
|
||||
|
||||
const fetchId3Tag = async (blob: BlobDescriptor) => {
|
||||
const id3Tag = await id3.fromUrl(blob.url).catch(e => console.warn(e));
|
||||
|
||||
if (id3Tag && id3Tag.kind == 'v2') {
|
||||
const id3v2 = id3Tag as ID3TagV2;
|
||||
if (id3v2.images[0].data) {
|
||||
const base64data = btoa(
|
||||
new Uint8Array(id3v2.images[0].data).reduce(function (data, byte) {
|
||||
return data + String.fromCharCode(byte);
|
||||
}, '')
|
||||
);
|
||||
const imageData = `data:${id3v2.images[0].type};base64,${base64data}`;
|
||||
return { ...blob, id3: id3Tag, imageData } as AudioBlob;
|
||||
}
|
||||
}
|
||||
return { ...blob, id3: id3Tag } as AudioBlob;
|
||||
};
|
||||
|
||||
const audioFiles = useMemo(
|
||||
() => blobs.filter(b => b.type?.startsWith('audio/')).sort((a, b) => (a.uploaded > b.uploaded ? -1 : 1)),
|
||||
[blobs]
|
||||
);
|
||||
|
||||
console.log(audioFiles);
|
||||
const audioFilesWithId3 = useQueries({
|
||||
queries: audioFiles.map(af => ({
|
||||
queryKey: ['id3', af.sha256],
|
||||
@ -272,10 +254,21 @@ const BlobList = ({ blobs, onDelete, title, className = '' }: BlobListProps) =>
|
||||
className="p-4 rounded-lg bg-base-300 m-2 relative flex flex-col"
|
||||
style={{ width: '24em' }}
|
||||
>
|
||||
{blob.data.id3 && (
|
||||
<div className="flex flex-row gap-4 pb-4">
|
||||
{blob.data.imageData && <img width="120" src={blob.data.imageData} />}
|
||||
|
||||
<div className="cover-image">
|
||||
<img
|
||||
width={96}
|
||||
height={96}
|
||||
src={blob.data?.id3?.cover || '/music-placeholder.png'}
|
||||
className="cursor-pointer rounded-md"
|
||||
onClick={() => dispatch({ type: 'SET_CURRENT_SONG', song: {url: blob.data.url, id3: blob.data.id3 }})}
|
||||
/>
|
||||
<PlayIcon
|
||||
className="play-icon "
|
||||
onClick={() => dispatch({ type: 'SET_CURRENT_SONG', song: {url: blob.data.url, id3: blob.data.id3 }})}
|
||||
></PlayIcon>
|
||||
</div>
|
||||
{blob.data.id3 && (
|
||||
<div className="flex flex-col pb-4 flex-grow">
|
||||
{blob.data.id3.title && <span className=" font-bold">{blob.data.id3.title}</span>}
|
||||
{blob.data.id3.artist && <span>{blob.data.id3.artist}</span>}
|
||||
@ -285,12 +278,10 @@ const BlobList = ({ blobs, onDelete, title, className = '' }: BlobListProps) =>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<audio className="w-full" src={blob.data.url} controls preload="metadata"></audio>
|
||||
|
||||
<div className="flex flex-grow flex-row text-xs pt-12 items-end">
|
||||
<div className="flex flex-grow flex-row text-xs items-end">
|
||||
<span>{formatFileSize(blob.data.size)}</span>
|
||||
<span className=" flex-grow text-right">{formatDate(blob.data.uploaded)}</span>
|
||||
</div>
|
||||
|
17
src/components/BottomNavBar/BottomNavBar.tsx
Normal file
17
src/components/BottomNavBar/BottomNavBar.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
interface BottomNavbarProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const BottomNavbar: React.FC<BottomNavbarProps> = ({ children }) => {
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 w-full bg-base-300 shadow-[0px_0px_4px_0px_rgba(0,0,0,.4)] ">
|
||||
<div className="navbar" >
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BottomNavbar;
|
@ -4,6 +4,8 @@ import './Layout.css';
|
||||
import { ArrowUpOnSquareIcon, MagnifyingGlassIcon, ServerStackIcon } from '@heroicons/react/24/outline';
|
||||
import { useEffect } from 'react';
|
||||
import ThemeSwitcher from '../ThemeSwitcher';
|
||||
import AudioPlayer from '../AudioPlayer';
|
||||
import BottomNavbar from '../BottomNavBar/BottomNavBar';
|
||||
|
||||
export const Layout = () => {
|
||||
const navigate = useNavigate();
|
||||
@ -57,6 +59,7 @@ export const Layout = () => {
|
||||
</div>
|
||||
<div className="navbar-center hidden md:block">{navItems}</div>
|
||||
<div className="navbar-end">
|
||||
|
||||
<ThemeSwitcher />
|
||||
<div className="avatar px-4">
|
||||
<div className="w-12 rounded-full">
|
||||
@ -67,6 +70,10 @@ export const Layout = () => {
|
||||
</div>
|
||||
|
||||
<div className="content">{<Outlet />}</div>
|
||||
<BottomNavbar>
|
||||
<AudioPlayer />
|
||||
|
||||
</BottomNavbar>
|
||||
<div className="footer">
|
||||
<span className="whitespace-nowrap block">
|
||||
made with 💜 by{' '}
|
||||
|
@ -12,6 +12,7 @@ import Upload from './pages/Upload.tsx';
|
||||
import Check from './pages/Check.tsx';
|
||||
|
||||
import { pdfjs } from 'react-pdf';
|
||||
import { GlobalProvider } from './GlobalState.tsx';
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.js', import.meta.url).toString();
|
||||
|
||||
@ -75,7 +76,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
>*/}
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NDKContextProvider>
|
||||
<GlobalProvider>
|
||||
<RouterProvider router={router} />
|
||||
</GlobalProvider>
|
||||
</NDKContextProvider>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
|
155
src/utils/id3.ts
Normal file
155
src/utils/id3.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import * as id3 from 'id3js';
|
||||
import { BlobDescriptor } from 'blossom-client-sdk';
|
||||
import { ID3TagV2 } from 'id3js/lib/id3Tag';
|
||||
|
||||
export type AudioBlob = BlobDescriptor & { id3?: ID3Tag };
|
||||
|
||||
export interface ID3Tag {
|
||||
artist?: string;
|
||||
album?: string;
|
||||
title?: string;
|
||||
year?: string;
|
||||
cover?: string;
|
||||
}
|
||||
|
||||
// Function to open IndexedDB
|
||||
function openIndexedDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open('bouquet', 1);
|
||||
|
||||
request.onupgradeneeded = event => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
db.createObjectStore('id3');
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Function to get ID3Tag from IndexedDB
|
||||
function getID3TagFromDB(db: IDBDatabase, hash: string): Promise<ID3Tag | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('id3', 'readonly');
|
||||
const store = transaction.objectStore('id3');
|
||||
const request = store.get(hash);
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result || null);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Function to save ID3Tag to IndexedDB
|
||||
function saveID3TagToDB(db: IDBDatabase, key: string, id3Tag: ID3Tag): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('id3', 'readwrite');
|
||||
const store = transaction.objectStore('id3');
|
||||
const request = store.put(id3Tag, key);
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Function to resize image
|
||||
function resizeImage(imageArray: ArrayBuffer, maxWidth: number, maxHeight: number): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const blob = new Blob([imageArray], { type: 'image/jpeg' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
|
||||
// Calculate the aspect ratio
|
||||
const aspectRatio = width / height;
|
||||
|
||||
// Adjust the width and height to maintain the aspect ratio within the max dimensions
|
||||
if (width > height) {
|
||||
if (width > maxWidth) {
|
||||
width = maxWidth;
|
||||
height = Math.round(width / aspectRatio);
|
||||
}
|
||||
} else {
|
||||
if (height > maxHeight) {
|
||||
height = maxHeight;
|
||||
width = Math.round(height * aspectRatio);
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
// Draw the image onto the canvas with the new dimensions
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
// Convert the canvas to a data URL
|
||||
const dataUrl = canvas.toDataURL('image/jpeg');
|
||||
resolve(dataUrl);
|
||||
} else {
|
||||
reject(new Error('Canvas context could not be retrieved'));
|
||||
}
|
||||
|
||||
URL.revokeObjectURL(url); // Clean up
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
reject(new Error('Image could not be loaded'));
|
||||
URL.revokeObjectURL(url); // Clean up
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
export const fetchId3Tag = async (blob: BlobDescriptor): Promise<AudioBlob> => {
|
||||
const db = await openIndexedDB();
|
||||
const cachedID3Tag = await getID3TagFromDB(db, blob.sha256);
|
||||
|
||||
if (cachedID3Tag) {
|
||||
return { ...blob, id3: cachedID3Tag } as AudioBlob;
|
||||
}
|
||||
|
||||
const id3Tag = await id3.fromUrl(blob.url).catch(e => console.warn(e));
|
||||
if (id3Tag) {
|
||||
const tagResult: ID3Tag = {
|
||||
title: id3Tag.title || undefined,
|
||||
artist: id3Tag.artist || undefined,
|
||||
album: id3Tag.album || undefined,
|
||||
year: id3Tag.year || undefined,
|
||||
};
|
||||
|
||||
if (id3Tag.kind == 'v2') {
|
||||
const id3v2 = id3Tag as ID3TagV2;
|
||||
if (id3v2.images[0].data) {
|
||||
tagResult.cover = await resizeImage(id3v2.images[0].data, 128, 128);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(blob.sha256, tagResult);
|
||||
|
||||
await saveID3TagToDB(db, blob.sha256, tagResult);
|
||||
return { ...blob, id3: tagResult };
|
||||
}
|
||||
console.log('No ID3 tag found for ' + blob.sha256);
|
||||
|
||||
return blob; // only when ID3 fails completely
|
||||
};
|
@ -30,7 +30,7 @@ console.log(allXTags);
|
||||
const groupedByX = groupBy(allXTags, item => item.x);
|
||||
return mapValues(groupedByX, v => v.map(e => e.ev));
|
||||
}, [fileMetaSub]);
|
||||
console.log(fileMetaEventsByHash);
|
||||
// console.log(fileMetaEventsByHash);
|
||||
|
||||
return fileMetaEventsByHash;
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user