diff --git a/public/music-placeholder.png b/public/music-placeholder.png new file mode 100644 index 0000000..da5135b Binary files /dev/null and b/public/music-placeholder.png differ diff --git a/src/GlobalState.tsx b/src/GlobalState.tsx new file mode 100644 index 0000000..c63b218 --- /dev/null +++ b/src/GlobalState.tsx @@ -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 } | undefined>(undefined); + +interface GlobalProviderProps { + children: ReactNode; +} + +const GlobalProvider: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(reducer, initialState); + + return {children}; +}; + +const useGlobalContext = () => { + const context = useContext(GlobalContext); + if (context === undefined) { + throw new Error('useGlobalContext must be used within a GlobalProvider'); + } + return context; +}; + +export { GlobalProvider, useGlobalContext }; diff --git a/src/components/AudioPlayer.tsx b/src/components/AudioPlayer.tsx new file mode 100644 index 0000000..3f28d33 --- /dev/null +++ b/src/components/AudioPlayer.tsx @@ -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(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) => { + const newVolume = parseFloat(event.target.value); + tuneVolume(newVolume); + }; + + return ( + currentSong && ( +
+
+ ) + ); +}; + +export default AudioPlayer; diff --git a/src/components/BlobList/BlobList.css b/src/components/BlobList/BlobList.css index 6d21afa..a77d850 100644 --- a/src/components/BlobList/BlobList.css +++ b/src/components/BlobList/BlobList.css @@ -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; +} + diff --git a/src/components/BlobList/BlobList.tsx b/src/components/BlobList/BlobList.tsx index 9c12f0d..898c588 100644 --- a/src/components/BlobList/BlobList.tsx +++ b/src/components/BlobList/BlobList.tsx @@ -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('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' && ( -
+
{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 && ( -
- {blob.data.imageData && } - +
+
+ dispatch({ type: 'SET_CURRENT_SONG', song: {url: blob.data.url, id3: blob.data.id3 }})} + /> + dispatch({ type: 'SET_CURRENT_SONG', song: {url: blob.data.url, id3: blob.data.id3 }})} + > +
+ {blob.data.id3 && (
{blob.data.id3.title && {blob.data.id3.title}} {blob.data.id3.artist && {blob.data.id3.artist}} @@ -285,12 +278,10 @@ const BlobList = ({ blobs, onDelete, title, className = '' }: BlobListProps) => )}
-
- )} + )} +
- - -
+
{formatFileSize(blob.data.size)} {formatDate(blob.data.uploaded)}
diff --git a/src/components/BottomNavBar/BottomNavBar.tsx b/src/components/BottomNavBar/BottomNavBar.tsx new file mode 100644 index 0000000..cc7fa91 --- /dev/null +++ b/src/components/BottomNavBar/BottomNavBar.tsx @@ -0,0 +1,17 @@ +import React, { ReactNode } from 'react'; + +interface BottomNavbarProps { + children: ReactNode; +} + +const BottomNavbar: React.FC = ({ children }) => { + return ( +
+
+ {children} +
+
+ ); +}; + +export default BottomNavbar; \ No newline at end of file diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx index 30df028..26996e0 100644 --- a/src/components/Layout/Layout.tsx +++ b/src/components/Layout/Layout.tsx @@ -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 = () => {
{navItems}
+
@@ -67,6 +70,10 @@ export const Layout = () => {
{}
+ + + +
made with 💜 by{' '} diff --git a/src/main.tsx b/src/main.tsx index a766f6b..6f89123 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( >*/} - + + + diff --git a/src/utils/id3.ts b/src/utils/id3.ts new file mode 100644 index 0000000..d56f442 --- /dev/null +++ b/src/utils/id3.ts @@ -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 { + 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 { + 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 { + 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 { + 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 => { + 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 +}; diff --git a/src/utils/useFileMetaEvents.ts b/src/utils/useFileMetaEvents.ts index b0d66e0..171aa57 100644 --- a/src/utils/useFileMetaEvents.ts +++ b/src/utils/useFileMetaEvents.ts @@ -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; };