feat: Added audio player

This commit is contained in:
florian 2024-05-19 23:33:14 +02:00
parent 18bf85e5ec
commit fbde04cc5e
10 changed files with 415 additions and 35 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

58
src/GlobalState.tsx Normal file
View 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 };

View 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;

View File

@ -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;
}

View File

@ -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],
@ -263,7 +245,7 @@ const BlobList = ({ blobs, onDelete, title, className = '' }: BlobListProps) =>
)}
{mode == 'audio' && (
<div className="blob-li st flex flex-wrap justify-center">
<div className="blob-list flex flex-wrap justify-center">
{audioFilesWithId3.map(
blob =>
blob.isSuccess && (
@ -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="flex flex-row gap-4 pb-4">
<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>

View 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;

View File

@ -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{' '}

View File

@ -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>
<RouterProvider router={router} />
<GlobalProvider>
<RouterProvider router={router} />
</GlobalProvider>
</NDKContextProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>

155
src/utils/id3.ts Normal file
View 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
};

View File

@ -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;
};