commit 717155ea22170ccb70574f286292f18a47d52e06 Author: angel Date: Thu Jun 19 02:09:24 2025 +0000 s diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..71d492b --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Navidrome Server Configuration +NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533 +NEXT_PUBLIC_NAVIDROME_USERNAME=your_username +NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password + +# Example for external server: +# NEXT_PUBLIC_NAVIDROME_URL=https://your-navidrome-server.com +# NEXT_PUBLIC_NAVIDROME_USERNAME=your_username +# NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..798d8cb --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"], + "rules": { + "@typescript-eslint/no-unused-vars": "off" + } +} diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 0000000..0c08d15 --- /dev/null +++ b/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "offbrandspotifydb" + } +} diff --git a/.github/workflows/jest.yml b/.github/workflows/jest.yml new file mode 100644 index 0000000..09682dd --- /dev/null +++ b/.github/workflows/jest.yml @@ -0,0 +1,34 @@ +name: Run Jest Tests + +on: + push: + branches: + - main + paths: + - 'apps/data/albums.ts' + - 'apps/data/artists.ts' + pull_request: + branches: + - main + paths: + - 'apps/data/albums.ts' + - 'apps/data/artists.ts' + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install dependencies + run: npm install + + - name: Run Jest tests + run: npm test \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..9032b63 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,23 @@ +name: Lint + +on: + pull_request: + branches: + - '**' + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + + - name: Install dependencies + run: npm install + + - name: Run Next.js lint + run: npm run lint \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..467434a --- /dev/null +++ b/.gitignore @@ -0,0 +1,75 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +firebase-debug.log* +firebase-debug.*.log* + +# Firebase cache +.firebase/ + +# Firebase config + +# Uncomment this if you'd like others to create their own Firebase project. +# For a team working on the same Firebase project(s), it is recommended to leave +# it commented so all members can deploy to the same project(s) in .firebaserc. +# .firebaserc + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +*.tsbuildinfo +next-env.d.ts + +# database +still-database/ + +.next/ +certificates +.vercel diff --git a/4xnored.png b/4xnored.png new file mode 100644 index 0000000..85501a4 Binary files /dev/null and b/4xnored.png differ diff --git a/MIGRATION_COMPLETE.md b/MIGRATION_COMPLETE.md new file mode 100644 index 0000000..4ce2b2e --- /dev/null +++ b/MIGRATION_COMPLETE.md @@ -0,0 +1,145 @@ +# Migration Summary: Firebase โ†’ Navidrome/Subsonic + +## โœ… Completed Migration Tasks + +### ๐Ÿ—‘๏ธ Removed Legacy Systems +- [x] **Firebase Dependencies**: Removed firebase, react-firebase-hooks packages +- [x] **Static Data Files**: Moved `app/data/` (albums.ts, artists.ts, playlists.ts) to backup +- [x] **Firebase Config**: Moved `app/firebase/` directory to backup +- [x] **Authentication System**: Removed Firebase Auth integration +- [x] **Database Connections**: Removed Firestore database calls + +### ๐Ÿš€ Implemented Navidrome Integration +- [x] **Navidrome API Client** (`lib/navidrome.ts`) + - Subsonic API authentication with token-based security + - All major endpoints: ping, getArtists, getAlbums, getAlbum, search3, etc. + - Stream URL generation for audio playback + - Cover art URL generation with size parameters + - Star/unstar functionality for favorites + - Scrobbling support for play tracking + +- [x] **React Context Provider** (`app/components/NavidromeContext.tsx`) + - Global state management for music library data + - Loading states for UI feedback + - Error handling and connection testing + - Data fetching with automatic refresh + - CRUD operations for playlists + +### ๐ŸŽต Updated Audio System +- [x] **AudioPlayerContext** - Completely rewritten for Navidrome + - Real audio streaming instead of static file URLs + - Queue management with Navidrome song objects + - Automatic scrobbling when tracks play + - Track conversion from Navidrome Song to playable Track format + +- [x] **AudioPlayer Component** + - Updated to handle Navidrome streaming URLs + - Dynamic cover art from Navidrome getCoverArt API + - Proper track metadata display (artist, album, duration) + +### ๐ŸŽจ Updated UI Components +- [x] **AlbumArtwork Component** + - Uses Navidrome Album interface + - Dynamic cover art with getCoverArt API + - Context menu integration with Navidrome playlists + - Proper album metadata display (year, genre, song count) + +- [x] **ArtistIcon Component** + - Uses Navidrome Artist interface + - Artist cover art support + - Album count display + - Star/unstar functionality in context menu + +### ๐Ÿ“„ Updated Pages +- [x] **Main Page** (`app/page.tsx`) + - Uses NavidromeContext for album data + - Loading states with skeleton UI + - Error handling for connection issues + - Recent and newest album sections + +- [x] **Album Detail Page** (`app/album/[id]/page.tsx`) + - Fetches album and songs from Navidrome + - Real-time song playback with streaming + - Star/unstar album functionality + - Proper track listing with metadata + +- [x] **Artist Page** (`app/artist/[artist]/page.tsx`) + - Artist details from Navidrome API + - Dynamic album grid for artist + - Star/unstar artist functionality + - Modern gradient header design + +- [x] **Library Pages** + - `app/library/albums/page.tsx` - Shows all albums in grid layout + - `app/library/artists/page.tsx` - Shows all artists in grid layout + - `app/library/playlists/page.tsx` - Playlist management with CRUD operations + +### ๐Ÿ”ง Configuration & Documentation +- [x] **Environment Configuration** + - `.env.example` with Navidrome connection settings + - Removed Firebase environment variables from package.json + +- [x] **Documentation** + - `NAVIDROME_MIGRATION.md` - Detailed migration guide + - Updated `README.md` with new setup instructions + - Feature documentation and troubleshooting + +- [x] **Type Safety** + - TypeScript interfaces matching Subsonic API responses + - Proper error handling throughout the application + - Type-safe component props and context values + +### ๐Ÿงช Testing +- [x] **Test Suite** (`__tests__/navidrome.test.ts`) + - API client functionality tests + - TypeScript interface validation + - URL generation testing + - Configuration validation + +## ๐ŸŽฏ Key Benefits Achieved + +### **Real Music Streaming** +- Replaced static MP3 URLs with dynamic Navidrome streaming +- Support for multiple audio formats and bitrates +- Proper audio metadata from music files + +### **Dynamic Library** +- No more manual JSON file management +- Auto-discovery of new music added to Navidrome +- Real-time library updates + +### **Enhanced Features** +- Scrobbling for play tracking +- Star/favorite functionality +- Playlist management (create, edit, delete) +- Search across entire music library +- High-quality album artwork + +### **Better Architecture** +- Removed Firebase dependency completely +- Self-hosted music solution +- Standards-based Subsonic API integration +- Type-safe development with proper interfaces + +## ๐Ÿ”„ Migration Path + +1. **Backup**: Old Firebase and static data moved to `-old` directories +2. **Dependencies**: Firebase packages removed, crypto built-in used +3. **Environment**: New `.env.local` needed with Navidrome credentials +4. **Data Flow**: `Static JSON โ†’ Firebase โ†’ Navidrome API` +5. **Authentication**: `Firebase Auth โ†’ Navidrome Server Authentication` +6. **Streaming**: `Static Files โ†’ Navidrome Stream API` + +## ๐Ÿšฆ Ready for Production + +The application is now fully migrated and ready for use with any Navidrome server. All core functionality has been preserved and enhanced: + +- โœ… Browse music library (albums, artists, songs) +- โœ… Audio playback with queue management +- โœ… Search functionality +- โœ… Playlist management +- โœ… Favorites/starring +- โœ… Responsive design +- โœ… Error handling and loading states + +**Next Steps**: Set up Navidrome server and configure connection in `.env.local` diff --git a/NAVIDROME_MIGRATION.md b/NAVIDROME_MIGRATION.md new file mode 100644 index 0000000..bc7e4a4 --- /dev/null +++ b/NAVIDROME_MIGRATION.md @@ -0,0 +1,151 @@ +# Navidrome Integration Migration + +This project has been migrated from a Firebase-based system with static data to use **Navidrome/Subsonic** as the backend music server. + +## What Changed + +### Removed: +- Firebase authentication and database +- Static album/artist data files +- Custom database URLs and tracklist JSON files + +### Added: +- Navidrome/Subsonic API integration +- Real-time music streaming +- Dynamic music library loading +- Album cover art from Navidrome +- Playlist management through Navidrome +- Star/favorite functionality +- Scrobbling support + +## Setup Instructions + +### 1. Install Navidrome + +First, you need to set up a Navidrome server. You can: + +- **Self-host**: Follow the [Navidrome installation guide](https://www.navidrome.org/docs/installation/) +- **Docker**: Use the official Docker image +- **Pre-built binaries**: Download from GitHub releases + +### 2. Configure Environment Variables + +Copy `.env.example` to `.env.local` and configure your Navidrome server: + +```bash +cp .env.example .env.local +``` + +Edit `.env.local`: +```env +NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533 +NEXT_PUBLIC_NAVIDROME_USERNAME=your_username +NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password +``` + +For production, use your actual Navidrome server URL: +```env +NEXT_PUBLIC_NAVIDROME_URL=https://your-navidrome-server.com +NEXT_PUBLIC_NAVIDROME_USERNAME=your_username +NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password +``` + +### 3. Install Dependencies + +Remove Firebase dependencies and install: + +```bash +pnpm install +``` + +### 4. Run the Application + +```bash +pnpm dev +``` + +## Features + +### Music Library +- **Albums**: Browse all albums in your Navidrome library +- **Artists**: Browse all artists with album counts +- **Songs**: Play individual tracks with streaming +- **Search**: Search across artists, albums, and songs +- **Playlists**: Create and manage playlists + +### Audio Player +- **Streaming**: Direct streaming from Navidrome server +- **Queue Management**: Add albums/artists to queue +- **Scrobbling**: Track listening history +- **Controls**: Play, pause, skip, volume control + +### User Features +- **Favorites**: Star/unstar albums, artists, and songs +- **Playlists**: Create, edit, and delete playlists +- **Recently Added**: See newest additions to your library +- **Album Artwork**: High quality cover art from Navidrome + +## API Integration + +The app uses the Subsonic API (compatible with Navidrome) with these endpoints: + +- `ping` - Test server connection +- `getArtists` - Get all artists +- `getAlbums` - Get albums (newest, recent, etc.) +- `getAlbum` - Get album details and tracks +- `search3` - Search music library +- `getPlaylists` - Get user playlists +- `stream` - Stream audio files +- `getCoverArt` - Get album/artist artwork +- `star/unstar` - Favorite items +- `scrobble` - Track listening + +## File Structure + +``` +lib/ + navidrome.ts # Navidrome API client +app/ + components/ + NavidromeContext.tsx # React context for Navidrome data + AudioPlayerContext.tsx # Updated for Navidrome streaming + album-artwork.tsx # Updated for Navidrome albums + artist-icon.tsx # Updated for Navidrome artists + AudioPlayer.tsx # Updated for streaming +``` + +## Migration Notes + +- **Authentication**: Removed Firebase auth (Navidrome handles users) +- **Data Source**: Now uses live music library instead of static JSON +- **Streaming**: Direct audio streaming instead of static file URLs +- **Cover Art**: Dynamic cover art from Navidrome instead of static images +- **Playlists**: Managed through Navidrome instead of static data + +## Troubleshooting + +### Connection Issues +1. Verify Navidrome server is running +2. Check URL, username, and password in `.env.local` +3. Ensure CORS is properly configured in Navidrome +4. Check network connectivity + +### Audio Issues +1. Verify audio files are properly imported in Navidrome +2. Check browser audio permissions +3. Ensure audio codecs are supported + +### Performance +1. Navidrome server performance affects loading times +2. Consider server location for streaming quality +3. Check network bandwidth for audio streaming + +## Development + +The app now uses TypeScript interfaces that match the Subsonic API responses. All components have been updated to work with the new data structure and real-time streaming. + +Key changes: +- Album interface now includes Navidrome-specific fields +- Artist interface includes album counts and cover art +- Song interface includes streaming URLs and metadata +- Playlist interface matches Navidrome playlist structure diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b7cae1 --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +![splash](https://github.com/sillyangel/project-still/blob/main/4xnored.png?raw=true) +# stillnavidrome (project still) +> still water, now with navidrome + +> project based on [shadcn/ui](https://github.com/shadcn-ui/ui)'s music template + +This is a modern music streaming web application built with [Next.js](https://nextjs.org/) and [shadcn/ui](https://ui.shadcn.com/), now powered by **Navidrome** for a complete self-hosted music streaming experience. + +**โœจ New**: Migrated from Firebase + static data to **Navidrome/Subsonic** integration for real music streaming! + +### Features + +- ๐ŸŽต **Real Music Streaming** via Navidrome/Subsonic API +- ๐Ÿ“ฑ **Modern UI** with shadcn/ui components +- ๐ŸŽจ **Dynamic Album Artwork** from your music library +- โญ **Favorites** - Star albums, artists, and songs +- ๐Ÿ“‹ **Playlist Management** - Create and manage playlists +- ๐Ÿ” **Search** - Find music across your entire library +- ๐ŸŽง **Audio Player** with queue management +- ๐Ÿ“Š **Scrobbling** - Track your listening history + +### Preview +![preview](https://github.com/sillyangel/project-still/blob/main/image.png?raw=true) + +## Quick Start + +### Prerequisites +- [Navidrome](https://www.navidrome.org/) server running +- Node.js 18+ and pnpm + +### Setup + +1. **Clone and install** + +```bash +git clone https://github.com/sillyangel/project-still.git +cd project-still/ +pnpm install +``` + +2. **Configure Navidrome connection** + +```bash +cp .env.example .env.local +``` + +Edit `.env.local` with your Navidrome server details: + +```env +NEXT_PUBLIC_NAVIDROME_URL=http://localhost:4533 +NEXT_PUBLIC_NAVIDROME_USERNAME=your_username +NEXT_PUBLIC_NAVIDROME_PASSWORD=your_password +``` + +3. **Run the development server** + +```bash +pnpm dev +``` + +Open [http://localhost:40625](http://localhost:40625) in your browser. + +## Migration from Firebase + +This project was migrated from Firebase to Navidrome. See [NAVIDROME_MIGRATION.md](./NAVIDROME_MIGRATION.md) for detailed migration notes and troubleshooting. + +## Tech Stack + +- **Frontend**: Next.js 15, React 19, TypeScript +- **UI**: shadcn/ui, Tailwind CSS, Radix UI +- **Backend**: Navidrome (Subsonic API compatible) +- **Audio**: Web Audio API with streaming +- **State**: React Context for global state management + +## Project Structure +``` +lib/ + navidrome.ts # Navidrome API client +app/ + components/ + NavidromeContext.tsx # Data provider for Navidrome + AudioPlayerContext.tsx # Audio player state management + album-artwork.tsx # Album display component + artist-icon.tsx # Artist display component + AudioPlayer.tsx # Main audio player + library/ # Library pages + albums/ # Albums view + artists/ # Artists view + playlists/ # Playlists view + album/[id]/ # Album detail page + artist/[artist]/ # Artist detail page +``` + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Test with your Navidrome server +5. Submit a pull request + +## License + +This project is licensed under the MIT License. + +## Acknowledgments + +- [shadcn/ui](https://ui.shadcn.com/) for the beautiful UI components +- [Navidrome](https://www.navidrome.org/) for the amazing music server +- [Subsonic API](http://www.subsonic.org/pages/api.jsp) for the API specification diff --git a/app/album/[id]/page.tsx b/app/album/[id]/page.tsx new file mode 100644 index 0000000..069b93f --- /dev/null +++ b/app/album/[id]/page.tsx @@ -0,0 +1,152 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { useParams } from 'next/navigation'; +import Image from 'next/image'; +import { Album, Song } from '@/lib/navidrome'; +import { useNavidrome } from '@/app/components/NavidromeContext'; +import { Play, Heart } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import Link from 'next/link'; +import { PlusIcon } from "@radix-ui/react-icons"; +import { useAudioPlayer } from '@/app/components/AudioPlayerContext' +import Loading from "@/app/components/loading"; +import { Separator } from '@/components/ui/separator'; +import { getNavidromeAPI } from '@/lib/navidrome'; + +export default function AlbumPage() { + const { id } = useParams(); + const [album, setAlbum] = useState(null); + const [tracklist, setTracklist] = useState([]); + const [loading, setLoading] = useState(true); + const [isStarred, setIsStarred] = useState(false); + const { getAlbum, starItem, unstarItem } = useNavidrome(); + const { playTrack, addAlbumToQueue, playAlbum, playAlbumFromTrack } = useAudioPlayer(); + const api = getNavidromeAPI(); + + useEffect(() => { + const fetchAlbum = async () => { + setLoading(true); + console.log(`Fetching album with id: ${id}`); + + try { + const albumData = await getAlbum(id as string); + setAlbum(albumData.album); + setTracklist(albumData.songs); + setIsStarred(!!albumData.album.starred); + console.log(`Album found: ${albumData.album.name}`); + } catch (error) { + console.error('Failed to fetch album:', error); + } + + setLoading(false); + }; + + if (id) { + fetchAlbum(); + } + }, [id, getAlbum]); + + const handleStar = async () => { + if (!album) return; + + try { + if (isStarred) { + await unstarItem(album.id, 'album'); + setIsStarred(false); + } else { + await starItem(album.id, 'album'); + setIsStarred(true); + } + } catch (error) { + console.error('Failed to star/unstar album:', error); + } + }; + + if (loading) { + return ; + } + + if (!album) { + return

Album not found

; + } + + const handlePlayClick = async (song: Song): Promise => { + if (!album) return; + + try { + await playAlbumFromTrack(album.id, song.id); + } catch (error) { + console.error('Failed to play album from track:', error); + } + }; + + const formatDuration = (duration: number): string => { + const minutes = Math.floor(duration / 60); + const seconds = duration % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; + }; + + // Get cover art URL with proper fallback + const coverArtUrl = album.coverArt + ? api.getCoverArtUrl(album.coverArt, 300) + : '/default-user.jpg'; + + return ( + <> +
+
+
+ {album.name} +
+
+

{album.name}

+ +
+ +

{album.artist}

+ + +
+

{album.songCount} songs โ€ข {album.year} โ€ข {album.genre}

+

Duration: {formatDuration(album.duration)}

+
+
+
+
+ + {tracklist.map((song, index) => ( +
handlePlayClick(song)}> +
+
{song.track || index + 1}
+
+

+ {song.title} +

+

+

{song.artist}

+

+
+
+
+

{formatDuration(song.duration)}

+
+
+ ))} +
+
+
+
+ + ); +} \ No newline at end of file diff --git a/app/artist/[artist]/page.tsx b/app/artist/[artist]/page.tsx new file mode 100644 index 0000000..ff1556c --- /dev/null +++ b/app/artist/[artist]/page.tsx @@ -0,0 +1,123 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { useParams } from 'next/navigation'; +import { Album, Artist } from '@/lib/navidrome'; +import { useNavidrome } from '@/app/components/NavidromeContext'; +import { AlbumArtwork } from '@/app/components/album-artwork'; +import Image from 'next/image'; +import { Button } from '@/components/ui/button'; +import { Heart } from 'lucide-react'; +import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; +import Loading from '@/app/components/loading'; +import { getNavidromeAPI } from '@/lib/navidrome'; + +export default function ArtistPage() { + const { artist: artistId } = useParams(); + const [isStarred, setIsStarred] = useState(false); + const [artistAlbums, setArtistAlbums] = useState([]); + const [loading, setLoading] = useState(true); + const [artist, setArtist] = useState(null); + const { getArtist, starItem, unstarItem } = useNavidrome(); + const api = getNavidromeAPI(); + + useEffect(() => { + const fetchArtistData = async () => { + setLoading(true); + try { + if (artistId) { + const artistData = await getArtist(artistId as string); + setArtist(artistData.artist); + setArtistAlbums(artistData.albums); + setIsStarred(!!artistData.artist.starred); + } + } catch (error) { + console.error('Failed to fetch artist data:', error); + } + setLoading(false); + }; + + fetchArtistData(); + }, [artistId, getArtist]); + + const handleStar = async () => { + if (!artist) return; + + try { + if (isStarred) { + await unstarItem(artist.id, 'artist'); + setIsStarred(false); + } else { + await starItem(artist.id, 'artist'); + setIsStarred(true); + } + } catch (error) { + console.error('Failed to star/unstar artist:', error); + } + }; + + if (loading) { + return ; + } + + if (!artist || artistAlbums.length === 0) { + return ( +
+

No albums found for this artist

+
+ ); + } + + // Get artist image URL with proper fallback + const artistImageUrl = artist.coverArt + ? api.getCoverArtUrl(artist.coverArt, 300) + : '/default-user.jpg'; + + return ( +
+
+ {/* Artist Header */} +
+
+
+ {artist.name} +
+
+

{artist.name}

+

{artist.albumCount} albums

+ +
+
+
+ + {/* Albums Section */} +
+

Albums

+ +
+ {artistAlbums.map((album) => ( + + ))} +
+ +
+
+
+
+ ); +} diff --git a/app/browse/page.tsx b/app/browse/page.tsx new file mode 100644 index 0000000..2feaf63 --- /dev/null +++ b/app/browse/page.tsx @@ -0,0 +1,87 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +'use client'; + +import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; +import { Separator } from '@/components/ui/separator'; +import { Tabs, TabsContent } from '@/components/ui/tabs'; +import { AlbumArtwork } from '@/app/components/album-artwork'; +import { ArtistIcon } from '@/app/components/artist-icon'; +import { useNavidrome } from '@/app/components/NavidromeContext'; +import Loading from '@/app/components/loading'; + +export default function BrowsePage() { + const { albums, artists, isLoading } = useNavidrome(); + + if (isLoading) { + return ; + } + + return ( +
+ <> + + +
+
+

+ Artists +

+

+ the people who make the music +

+
+
+ +
+
+ +
+ {artists.map((artist) => ( + + ))} +
+ +
+
+
+
+
+

+ Browse +

+

+ Browse the full collection of music available. +

+
+
+ +
+ +
+
+ {albums.map((album) => ( + + ))} +
+
+ +
+
+
+
+ +
+ ); +} \ No newline at end of file diff --git a/app/components/AudioPlayer.tsx b/app/components/AudioPlayer.tsx new file mode 100644 index 0000000..3c1e249 --- /dev/null +++ b/app/components/AudioPlayer.tsx @@ -0,0 +1,202 @@ +'use client'; +import React, { useEffect, useRef, useState } from 'react'; +import Image from 'next/image'; +import { useAudioPlayer } from '@/app/components/AudioPlayerContext'; +import { FaPlay, FaPause, FaVolumeHigh, FaForward, FaBackward } from "react-icons/fa6"; +import ColorThief from '@neutrixs/colorthief'; +import { Progress } from '@/components/ui/progress'; +import { useToast } from '@/hooks/use-toast'; + +export const AudioPlayer: React.FC = () => { + const { currentTrack, playPreviousTrack, addToQueue, playNextTrack, clearQueue } = useAudioPlayer(); + const audioRef = useRef(null); + const [progress, setProgress] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + const [showVolumeSlider, setShowVolumeSlider] = useState(false); + const [volume, setVolume] = useState(1); + const [isClient, setIsClient] = useState(false); + const audioCurrent = audioRef.current; + const { toast } = useToast(); + + useEffect(() => { + setIsClient(true); + }, []); + + // Save position when component unmounts or track changes + useEffect(() => { + return () => { + const audioCurrent = audioRef.current; + if (audioCurrent && currentTrack && audioCurrent.currentTime > 10) { + localStorage.setItem(`navidrome-track-time-${currentTrack.id}`, audioCurrent.currentTime.toString()); + } + }; + }, [currentTrack?.id]); + + useEffect(() => { + const audioCurrent = audioRef.current; + + if (currentTrack && audioCurrent && audioCurrent.src !== currentTrack.url) { + audioCurrent.src = currentTrack.url; + + // Check for saved timestamp (only restore if more than 10 seconds in) + const savedTime = localStorage.getItem(`navidrome-track-time-${currentTrack.id}`); + if (savedTime) { + const time = parseFloat(savedTime); + // Only restore if we were at least 10 seconds in and not near the end + if (time > 10 && time < (currentTrack.duration - 30)) { + const restorePosition = () => { + if (audioCurrent.readyState >= 2) { // HAVE_CURRENT_DATA + audioCurrent.currentTime = time; + audioCurrent.removeEventListener('loadeddata', restorePosition); + } + }; + + if (audioCurrent.readyState >= 2) { + audioCurrent.currentTime = time; + } else { + audioCurrent.addEventListener('loadeddata', restorePosition); + } + } + } + + audioCurrent.play(); + setIsPlaying(true); + } + }, [currentTrack?.id, currentTrack?.url]); + + useEffect(() => { + const audioCurrent = audioRef.current; + let lastSavedTime = 0; + + const updateProgress = () => { + if (audioCurrent && currentTrack) { + setProgress((audioCurrent.currentTime / audioCurrent.duration) * 100); + + // Save current time every 10 seconds, but only if we've moved forward significantly + const currentTime = audioCurrent.currentTime; + if (Math.abs(currentTime - lastSavedTime) >= 10 && currentTime > 10) { + localStorage.setItem(`navidrome-track-time-${currentTrack.id}`, currentTime.toString()); + lastSavedTime = currentTime; + } + } + }; + + const handleTrackEnd = () => { + if (currentTrack) { + // Clear saved time when track ends + localStorage.removeItem(`navidrome-track-time-${currentTrack.id}`); + } + playNextTrack(); + }; + + const handleSeeked = () => { + if (audioCurrent && currentTrack) { + // Save immediately when user seeks + localStorage.setItem(`navidrome-track-time-${currentTrack.id}`, audioCurrent.currentTime.toString()); + lastSavedTime = audioCurrent.currentTime; + } + }; + + if (audioCurrent) { + audioCurrent.addEventListener('timeupdate', updateProgress); + audioCurrent.addEventListener('ended', handleTrackEnd); + audioCurrent.addEventListener('seeked', handleSeeked); + } + + return () => { + if (audioCurrent) { + audioCurrent.removeEventListener('timeupdate', updateProgress); + audioCurrent.removeEventListener('ended', handleTrackEnd); + audioCurrent.removeEventListener('seeked', handleSeeked); + } + }; + }, [playNextTrack, currentTrack]); + + const handleProgressClick = (e: React.MouseEvent) => { + if (audioCurrent && currentTrack) { + const rect = e.currentTarget.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const newTime = (clickX / rect.width) * audioCurrent.duration; + audioCurrent.currentTime = newTime; + + // Save the new position immediately + localStorage.setItem(`navidrome-track-time-${currentTrack.id}`, newTime.toString()); + } + }; + + const togglePlayPause = () => { + if (audioCurrent) { + if (isPlaying) { + audioCurrent.pause(); + } else { + audioCurrent.play(); + } + setIsPlaying(!isPlaying); + } + }; + const handleVolumeChange = (e: React.ChangeEvent) => { + const newVolume = parseFloat(e.target.value); + setVolume(newVolume); + if (audioCurrent) { + audioCurrent.volume = newVolume; + } + }; + + function formatTime(seconds: number): string { + if (isNaN(seconds) || seconds < 0) { + return "0:00"; + } + const minutes = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60).toString().padStart(2, "0"); + return `${minutes}:${secs}`; + } + + if (!isClient) { + return null; + } + + return ( +
+ {currentTrack ? ( +
+ {currentTrack.name} +
+

{currentTrack.name}

+

{currentTrack.artist}

+
+
+
+ + + +
+
+ + {formatTime(audioCurrent?.currentTime ?? 0)} + + + + {formatTime(audioCurrent?.duration ?? 0)} + +
+
+
+ ) : ( +

No track playing

+ )} +
+ ); +}; \ No newline at end of file diff --git a/app/components/AudioPlayerContext.tsx b/app/components/AudioPlayerContext.tsx new file mode 100644 index 0000000..cf06817 --- /dev/null +++ b/app/components/AudioPlayerContext.tsx @@ -0,0 +1,311 @@ +'use client'; + +import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react'; +import { Song, Album, Artist } from '@/lib/navidrome'; +import { getNavidromeAPI } from '@/lib/navidrome'; +import { useToast } from "@/hooks/use-toast"; + +interface Track { + id: string; + name: string; + url: string; + artist: string; + album: string; + duration: number; + coverArt?: string; + albumId: string; + artistId: string; +} + +interface AudioPlayerContextProps { + currentTrack: Track | null; + playTrack: (track: Track) => void; + queue: Track[]; + addToQueue: (track: Track) => void; + playNextTrack: () => void; + clearQueue: () => void; + addAlbumToQueue: (albumId: string) => Promise; + playAlbum: (albumId: string) => Promise; + playAlbumFromTrack: (albumId: string, startingSongId: string) => Promise; + removeTrackFromQueue: (index: number) => void; + skipToTrackInQueue: (index: number) => void; + addArtistToQueue: (artistId: string) => Promise; + playPreviousTrack: () => void; + isLoading: boolean; +} + +const AudioPlayerContext = createContext(undefined); + +export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [currentTrack, setCurrentTrack] = useState(null); + const [queue, setQueue] = useState([]); + const [playedTracks, setPlayedTracks] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const { toast } = useToast(); + const api = useMemo(() => getNavidromeAPI(), []); + + useEffect(() => { + const savedQueue = localStorage.getItem('navidrome-audioQueue'); + if (savedQueue) { + try { + setQueue(JSON.parse(savedQueue)); + } catch (error) { + console.error('Failed to parse saved queue:', error); + } + } + }, []); + + useEffect(() => { + localStorage.setItem('navidrome-audioQueue', JSON.stringify(queue)); + }, [queue]); + + useEffect(() => { + const savedCurrentTrack = localStorage.getItem('navidrome-currentTrack'); + if (savedCurrentTrack) { + try { + setCurrentTrack(JSON.parse(savedCurrentTrack)); + } catch (error) { + console.error('Failed to parse saved current track:', error); + } + } + }, []); + + useEffect(() => { + if (currentTrack) { + localStorage.setItem('navidrome-currentTrack', JSON.stringify(currentTrack)); + } else { + localStorage.removeItem('navidrome-currentTrack'); + } + }, [currentTrack]); + + const songToTrack = useMemo(() => (song: Song): Track => { + return { + id: song.id, + name: song.title, + url: api.getStreamUrl(song.id), + artist: song.artist, + album: song.album, + duration: song.duration, + coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined, + albumId: song.albumId, + artistId: song.artistId + }; + }, [api]); + + const playTrack = useCallback((track: Track) => { + if (currentTrack) { + setPlayedTracks((prev) => [...prev, currentTrack]); + } + setCurrentTrack(track); + + // Scrobble the track + api.scrobble(track.id).catch(error => { + console.error('Failed to scrobble track:', error); + }); + }, [currentTrack, api]); + + const addToQueue = useCallback((track: Track) => { + setQueue((prevQueue) => [...prevQueue, track]); + }, []); + + const clearQueue = useCallback(() => { + setQueue([]); + }, []); + + const removeTrackFromQueue = useCallback((index: number) => { + setQueue((prevQueue) => prevQueue.filter((_, i) => i !== index)); + }, []); + + const playNextTrack = () => { + if (queue.length > 0) { + const nextTrack = queue[0]; + setQueue((prevQueue) => prevQueue.slice(1)); + playTrack(nextTrack); + } + }; + + const playPreviousTrack = () => { + if (playedTracks.length > 0) { + const previousTrack = playedTracks[playedTracks.length - 1]; + setPlayedTracks((prevPlayedTracks) => prevPlayedTracks.slice(0, -1)); + + // Add current track back to beginning of queue + if (currentTrack) { + setQueue((prevQueue) => [currentTrack, ...prevQueue]); + } + + setCurrentTrack(previousTrack); + } + }; + + const addAlbumToQueue = async (albumId: string) => { + setIsLoading(true); + try { + const { album, songs } = await api.getAlbum(albumId); + const tracks = songs.map(songToTrack); + setQueue((prevQueue) => [...prevQueue, ...tracks]); + + toast({ + title: "Album Added", + description: `Added "${album.name}" to queue`, + }); + } catch (error) { + console.error('Failed to add album to queue:', error); + toast({ + variant: "destructive", + title: "Error", + description: "Failed to add album to queue", + }); + } finally { + setIsLoading(false); + } + }; + + const addArtistToQueue = async (artistId: string) => { + setIsLoading(true); + try { + const { artist, albums } = await api.getArtist(artistId); + + // Add all albums from this artist to queue + for (const album of albums) { + const { songs } = await api.getAlbum(album.id); + const tracks = songs.map(songToTrack); + setQueue((prevQueue) => [...prevQueue, ...tracks]); + } + + toast({ + title: "Artist Added", + description: `Added all albums by "${artist.name}" to queue`, + }); + } catch (error) { + console.error('Failed to add artist to queue:', error); + toast({ + variant: "destructive", + title: "Error", + description: "Failed to add artist to queue", + }); + } finally { + setIsLoading(false); + } + }; + const playAlbum = async (albumId: string) => { + setIsLoading(true); + try { + const { album, songs } = await api.getAlbum(albumId); + const tracks = songs.map(songToTrack); + + // Clear the queue and set the new tracks + setQueue(tracks.slice(1)); // All tracks except the first one + + // Play the first track immediately + if (tracks.length > 0) { + playTrack(tracks[0]); + } + + toast({ + title: "Playing Album", + description: `Now playing "${album.name}"`, + }); + } catch (error) { + console.error('Failed to play album:', error); + toast({ + variant: "destructive", + title: "Error", + description: "Failed to play album", + }); + } finally { + setIsLoading(false); + } + }; + + const playAlbumFromTrack = async (albumId: string, startingSongId: string) => { + setIsLoading(true); + try { + const { album, songs } = await api.getAlbum(albumId); + const tracks = songs.map(songToTrack); + + // Find the starting track index + const startingIndex = tracks.findIndex(track => track.id === startingSongId); + + if (startingIndex === -1) { + throw new Error('Starting song not found in album'); + } + + // Clear the queue and set the remaining tracks after the starting track + setQueue(tracks.slice(startingIndex + 1)); + + // Play the starting track immediately + playTrack(tracks[startingIndex]); + + toast({ + title: "Playing Album", + description: `Playing "${album.name}" from "${tracks[startingIndex].name}"`, + }); + } catch (error) { + console.error('Failed to play album from track:', error); + toast({ + variant: "destructive", + title: "Error", + description: "Failed to play album from selected track", + }); + } finally { + setIsLoading(false); + } + }; + + const skipToTrackInQueue = useCallback((index: number) => { + if (index >= 0 && index < queue.length) { + const targetTrack = queue[index]; + // Remove all tracks before the target track (including the target track) + setQueue((prevQueue) => prevQueue.slice(index + 1)); + // Play the target track + playTrack(targetTrack); + } + }, [queue, playTrack]); + + const contextValue = useMemo(() => ({ + currentTrack, + playTrack, + queue, + addToQueue, + playNextTrack, + clearQueue, + addAlbumToQueue, + removeTrackFromQueue, + addArtistToQueue, + playPreviousTrack, + isLoading, + playAlbum, + playAlbumFromTrack, + skipToTrackInQueue + }), [ + currentTrack, + queue, + isLoading, + playTrack, + addToQueue, + playNextTrack, + clearQueue, + addAlbumToQueue, + removeTrackFromQueue, + addArtistToQueue, + playPreviousTrack, + playAlbum, + playAlbumFromTrack, + skipToTrackInQueue + ]); + + return ( + + {children} + + ); +}; + +export const useAudioPlayer = () => { + const context = useContext(AudioPlayerContext); + if (!context) { + throw new Error('useAudioPlayer must be used within an AudioPlayerProvider'); + } + return context; +}; \ No newline at end of file diff --git a/app/components/NavidromeContext.tsx b/app/components/NavidromeContext.tsx new file mode 100644 index 0000000..5482f52 --- /dev/null +++ b/app/components/NavidromeContext.tsx @@ -0,0 +1,295 @@ +'use client'; +import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react'; +import { getNavidromeAPI, Album, Artist, Song, Playlist } from '@/lib/navidrome'; + +interface NavidromeContextType { + // Data + albums: Album[]; + artists: Artist[]; + playlists: Playlist[]; + + // Loading states + isLoading: boolean; + albumsLoading: boolean; + artistsLoading: boolean; + playlistsLoading: boolean; + + // Error states + error: string | null; + + // Methods + searchMusic: (query: string) => Promise<{ artists: Artist[]; albums: Album[]; songs: Song[] }>; + getAlbum: (albumId: string) => Promise<{ album: Album; songs: Song[] }>; + getArtist: (artistId: string) => Promise<{ artist: Artist; albums: Album[] }>; + getPlaylist: (playlistId: string) => Promise<{ playlist: Playlist; songs: Song[] }>; + getAllSongs: () => Promise; + refreshData: () => Promise; + createPlaylist: (name: string, songIds?: string[]) => Promise; + updatePlaylist: (playlistId: string, name?: string, comment?: string, songIds?: string[]) => Promise; + deletePlaylist: (playlistId: string) => Promise; + starItem: (id: string, type: 'song' | 'album' | 'artist') => Promise; + unstarItem: (id: string, type: 'song' | 'album' | 'artist') => Promise; + scrobble: (songId: string) => Promise; +} + +const NavidromeContext = createContext(undefined); + +interface NavidromeProviderProps { + children: ReactNode; +} + +export const NavidromeProvider: React.FC = ({ children }) => { + const [albums, setAlbums] = useState([]); + const [artists, setArtists] = useState([]); + const [playlists, setPlaylists] = useState([]); + + const [albumsLoading, setAlbumsLoading] = useState(false); + const [artistsLoading, setArtistsLoading] = useState(false); + const [playlistsLoading, setPlaylistsLoading] = useState(false); + + const [error, setError] = useState(null); + + const isLoading = albumsLoading || artistsLoading || playlistsLoading; + + const api = getNavidromeAPI(); + + const loadAlbums = async () => { + setAlbumsLoading(true); + setError(null); + try { + const recentAlbums = await api.getAlbums('recent', 50); + const newestAlbums = await api.getAlbums('newest', 50); + + // Combine and deduplicate albums + const allAlbums = [...recentAlbums, ...newestAlbums]; + const uniqueAlbums = allAlbums.filter((album, index, self) => + index === self.findIndex(a => a.id === album.id) + ); + + setAlbums(uniqueAlbums); + } catch (err) { + console.error('Failed to load albums:', err); + setError('Failed to load albums'); + } finally { + setAlbumsLoading(false); + } + }; + + const loadArtists = async () => { + setArtistsLoading(true); + setError(null); + try { + const artistList = await api.getArtists(); + setArtists(artistList); + } catch (err) { + console.error('Failed to load artists:', err); + setError('Failed to load artists'); + } finally { + setArtistsLoading(false); + } + }; + + const loadPlaylists = async () => { + setPlaylistsLoading(true); + setError(null); + try { + const playlistList = await api.getPlaylists(); + setPlaylists(playlistList); + } catch (err) { + console.error('Failed to load playlists:', err); + setError('Failed to load playlists'); + } finally { + setPlaylistsLoading(false); + } + }; + + const refreshData = async () => { + await Promise.all([loadAlbums(), loadArtists(), loadPlaylists()]); + }; + + const searchMusic = async (query: string) => { + setError(null); + try { + return await api.search(query); + } catch (err) { + console.error('Search failed:', err); + setError('Search failed'); + return { artists: [], albums: [], songs: [] }; + } + }; + + const getAlbum = async (albumId: string) => { + setError(null); + try { + return await api.getAlbum(albumId); + } catch (err) { + console.error('Failed to get album:', err); + setError('Failed to get album'); + throw err; + } + }; + + const getArtist = async (artistId: string) => { + setError(null); + try { + return await api.getArtist(artistId); + } catch (err) { + console.error('Failed to get artist:', err); + setError('Failed to get artist'); + throw err; + } + }; + + const getPlaylist = async (playlistId: string) => { + setError(null); + try { + return await api.getPlaylist(playlistId); + } catch (err) { + console.error('Failed to get playlist:', err); + setError('Failed to get playlist'); + throw err; + } + }; + + const getAllSongs = async () => { + setError(null); + try { + return await api.getAllSongs(); + } catch (err) { + console.error('Failed to get all songs:', err); + setError('Failed to get all songs'); + throw err; + } + }; + + const createPlaylist = async (name: string, songIds?: string[]) => { + setError(null); + try { + const playlist = await api.createPlaylist(name, songIds); + await loadPlaylists(); // Refresh playlists + return playlist; + } catch (err) { + console.error('Failed to create playlist:', err); + setError('Failed to create playlist'); + throw err; + } + }; + + const updatePlaylist = async (playlistId: string, name?: string, comment?: string, songIds?: string[]) => { + setError(null); + try { + await api.updatePlaylist(playlistId, name, comment, songIds); + await loadPlaylists(); // Refresh playlists + } catch (err) { + console.error('Failed to update playlist:', err); + setError('Failed to update playlist'); + throw err; + } + }; + + const deletePlaylist = async (playlistId: string) => { + setError(null); + try { + await api.deletePlaylist(playlistId); + await loadPlaylists(); // Refresh playlists + } catch (err) { + console.error('Failed to delete playlist:', err); + setError('Failed to delete playlist'); + throw err; + } + }; + + const starItem = async (id: string, type: 'song' | 'album' | 'artist') => { + setError(null); + try { + await api.star(id, type); + } catch (err) { + console.error('Failed to star item:', err); + setError('Failed to star item'); + throw err; + } + }; + + const unstarItem = async (id: string, type: 'song' | 'album' | 'artist') => { + setError(null); + try { + await api.unstar(id, type); + } catch (err) { + console.error('Failed to unstar item:', err); + setError('Failed to unstar item'); + throw err; + } + }; + + const scrobble = async (songId: string) => { + try { + await api.scrobble(songId); + } catch (err) { + console.error('Failed to scrobble:', err); + // Don't set error state for scrobbling failures as they're not critical + } + }; + + useEffect(() => { + // Test connection and load initial data + const initialize = async () => { + try { + const isConnected = await api.ping(); + if (isConnected) { + await refreshData(); + } else { + setError('Failed to connect to Navidrome server'); + } + } catch (err) { + console.error('Failed to initialize Navidrome:', err); + setError('Failed to initialize Navidrome connection'); + } + }; + + initialize(); + }, []); + + const value: NavidromeContextType = { + // Data + albums, + artists, + playlists, + + // Loading states + isLoading, + albumsLoading, + artistsLoading, + playlistsLoading, + + // Error state + error, + + // Methods + searchMusic, + getAlbum, + getArtist, + getPlaylist, + getAllSongs, + refreshData, + createPlaylist, + updatePlaylist, + deletePlaylist, + starItem, + unstarItem, + scrobble + }; + + return ( + + {children} + + ); +}; + +export const useNavidrome = (): NavidromeContextType => { + const context = useContext(NavidromeContext); + if (context === undefined) { + throw new Error('useNavidrome must be used within a NavidromeProvider'); + } + return context; +}; diff --git a/app/components/album-artwork.tsx b/app/components/album-artwork.tsx new file mode 100644 index 0000000..181b2bc --- /dev/null +++ b/app/components/album-artwork.tsx @@ -0,0 +1,134 @@ +'use client'; + +import Image from "next/image" +import { PlusCircledIcon } from "@radix-ui/react-icons" +import { useRouter } from 'next/navigation'; + +import { cn } from "@/lib/utils" +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from "../../components/ui/context-menu" + +import { Album } from "@/lib/navidrome" +import { useNavidrome } from "./NavidromeContext" +import Link from "next/link"; +import { useAudioPlayer } from "@/app/components/AudioPlayerContext"; +import { getNavidromeAPI } from "@/lib/navidrome"; + +interface AlbumArtworkProps extends React.HTMLAttributes { + album: Album + aspectRatio?: "portrait" | "square" + width?: number + height?: number +} + +export function AlbumArtwork({ + album, + aspectRatio = "portrait", + width, + height, + className, + ...props +}: AlbumArtworkProps) { + const router = useRouter(); + const { addAlbumToQueue } = useAudioPlayer(); + const { playlists, starItem, unstarItem } = useNavidrome(); + const api = getNavidromeAPI(); + + const handleClick = () => { + router.push(`/album/${album.id}`); + }; + + const handleAddToQueue = () => { + addAlbumToQueue(album.id); + }; + + const handleStar = () => { + if (album.starred) { + unstarItem(album.id, 'album'); + } else { + starItem(album.id, 'album'); + } + }; + + // Get cover art URL with proper fallback + const coverArtUrl = album.coverArt + ? api.getCoverArtUrl(album.coverArt, 300) + : '/default-user.jpg'; + + return ( +
+ + +
+ {album.name} +
+
+ + + {album.starred ? 'Remove from Favorites' : 'Add to Favorites'} + + + Add to Playlist + + + + New Playlist + + + {playlists.map((playlist) => ( + + + + + {playlist.name} + + ))} + + + + Add Album to Queue + Play Next + Play Later + + + {album.starred ? 'โ˜… Starred' : 'โ˜† Star'} + + Share + +
+
+

{album.name}

+

+ {album.artist} +

+
+
+ ) +} \ No newline at end of file diff --git a/app/components/artist-icon.tsx b/app/components/artist-icon.tsx new file mode 100644 index 0000000..88894d8 --- /dev/null +++ b/app/components/artist-icon.tsx @@ -0,0 +1,125 @@ +'use client'; + +import Image from "next/image" +import { PlusCircledIcon } from "@radix-ui/react-icons" +import { useRouter } from 'next/navigation'; + +import { cn } from "@/lib/utils" +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from "../../components/ui/context-menu" + +import { Artist } from "@/lib/navidrome" +import { useNavidrome } from "./NavidromeContext" +import { useAudioPlayer } from "@/app/components/AudioPlayerContext"; +import { getNavidromeAPI } from "@/lib/navidrome"; + +interface ArtistIconProps extends React.HTMLAttributes { + artist: Artist + size?: number +} + +export function ArtistIcon({ + artist, + size = 150, + className, + ...props +}: ArtistIconProps) { + const router = useRouter(); + const { addArtistToQueue } = useAudioPlayer(); + const { playlists, starItem, unstarItem } = useNavidrome(); + const api = getNavidromeAPI(); + + const handleClick = () => { + router.push(`/artist/${artist.id}`); + }; + + const handleAddToQueue = () => { + addArtistToQueue(artist.id); + }; + + const handleStar = () => { + if (artist.starred) { + unstarItem(artist.id, 'artist'); + } else { + starItem(artist.id, 'artist'); + } + }; + + // Get cover art URL with proper fallback + const artistImageUrl = artist.coverArt + ? api.getCoverArtUrl(artist.coverArt, 200) + : '/default-user.jpg'; + + return ( +
+ + +
+ {artist.name} +
+
+ + + {artist.starred ? 'Remove from Favorites' : 'Add to Favorites'} + + + Add to Playlist + + + + New Playlist + + + {playlists.map((playlist) => ( + + + + + {playlist.name} + + ))} + + + + Add All Songs to Queue + Play Next + Play Later + + + {artist.starred ? 'โ˜… Starred' : 'โ˜† Star'} + + Share + +
+
+

{artist.name}

+

{artist.albumCount} albums

+
+
+ ); +} diff --git a/app/components/feedbackpopup.tsx b/app/components/feedbackpopup.tsx new file mode 100644 index 0000000..00f355b --- /dev/null +++ b/app/components/feedbackpopup.tsx @@ -0,0 +1,46 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; + +const FeedbackPopup: React.FC = () => { + const [showPopup, setShowPopup] = useState(false); + + useEffect(() => { + const isFirstVisit = localStorage.getItem('isFirstVisit'); + if (!isFirstVisit) { + setShowPopup(true); + localStorage.setItem('isFirstVisit', 'true'); + } + }, []); + + const handleClosePopup = () => { + setShowPopup(false); + }; + + if (!showPopup) return null; + + return ( +
+
+

We value your feedback!

+

Please take a moment to fill out our feedback form.

+ + Give Feedback + + +
+
+ ); +}; + +export default FeedbackPopup; \ No newline at end of file diff --git a/app/components/ihateserverside.tsx b/app/components/ihateserverside.tsx new file mode 100644 index 0000000..665105c --- /dev/null +++ b/app/components/ihateserverside.tsx @@ -0,0 +1,64 @@ +'use client'; + +import React, { useState } from 'react'; +import { Menu } from "@/app/components/menu"; +import { Sidebar } from "@/app/components/sidebar"; +import { useNavidrome } from "@/app/components/NavidromeContext"; +import { AudioPlayer } from "./AudioPlayer"; +import { Toaster } from "@/components/ui/toaster" + +interface IhateserversideProps { + children: React.ReactNode; +} + +const Ihateserverside: React.FC = ({ children }) => { + const [isSidebarVisible, setIsSidebarVisible] = useState(true); + const [isStatusBarVisible, setIsStatusBarVisible] = useState(true); + const [isSidebarHidden, setIsSidebarHidden] = useState(false); + const { playlists } = useNavidrome(); + + const handleTransitionEnd = () => { + if (!isSidebarVisible) { + setIsSidebarHidden(true); // This will fully hide the sidebar after transition + } + }; + return ( +
+ {/* Top Menu */} +
+ setIsSidebarVisible(!isSidebarVisible)} + isSidebarVisible={isSidebarVisible} + toggleStatusBar={() => setIsStatusBarVisible(!isStatusBarVisible)} + isStatusBarVisible={isStatusBarVisible} + /> +
+ + {/* Main Content Area */} +
+ {isSidebarVisible && ( +
+ +
+ )} +
+
{children}
+
+
+ + {/* Audio Player */} + {isStatusBarVisible && ( +
+ +
+ )} + +
+ ); +}; + +export default Ihateserverside; \ No newline at end of file diff --git a/app/components/loading.tsx b/app/components/loading.tsx new file mode 100644 index 0000000..6e12876 --- /dev/null +++ b/app/components/loading.tsx @@ -0,0 +1,18 @@ +'use client'; + +import React from 'react'; + +const Loading: React.FC = () => { + return ( + <> +
+
+
+

Loading...

+
+
+ + ); +}; + +export default Loading; \ No newline at end of file diff --git a/app/components/menu.tsx b/app/components/menu.tsx new file mode 100644 index 0000000..62ecf15 --- /dev/null +++ b/app/components/menu.tsx @@ -0,0 +1,294 @@ +import { useCallback } from "react"; +import { useRouter } from 'next/navigation'; +import Image from "next/image"; +import { Github, Mail } from "lucide-react" +import { + Menubar, + MenubarCheckboxItem, + MenubarContent, + MenubarLabel, + MenubarItem, + MenubarMenu, + MenubarSeparator, + MenubarShortcut, + MenubarSub, + MenubarSubContent, + MenubarSubTrigger, + MenubarTrigger, + } from "@/components/ui/menubar" +import { useState, useEffect } from "react" +import { Button } from "@/components/ui/button" +import { Separator } from '@/components/ui/separator'; +import { useNavidrome } from "./NavidromeContext"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + } from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" + +interface MenuProps { + toggleSidebar: () => void; + isSidebarVisible: boolean; + toggleStatusBar: () => void; + isStatusBarVisible: boolean; +} + +export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatusBarVisible }: MenuProps) { + const [isFullScreen, setIsFullScreen] = useState(false) + const router = useRouter(); + const [open, setOpen] = useState(false); + const { isConnected } = useNavidrome(); + + // For this demo, we'll show connection status instead of user auth + const connectionStatus = isConnected ? "Connected to Navidrome" : "Not connected"; + + const handleFullScreen = useCallback(() => { + if (!isFullScreen) { + document.documentElement.requestFullscreen() + } else { + document.exitFullscreen() + } + setIsFullScreen(!isFullScreen) + }, [isFullScreen]) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key === ',') { + event.preventDefault(); + router.push('/settings'); + } + if ((event.metaKey || event.ctrlKey) && event.key === 's') { + event.preventDefault(); + toggleSidebar(); + } + if ((event.metaKey || event.ctrlKey) && event.key === 'f') { + event.preventDefault(); + handleFullScreen(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [router, toggleSidebar, handleFullScreen]); + + return ( + <> + + + offbrand spotify + + setOpen(true)}>About Music + + router.push('/settings')}> + Preferences โŒ˜, + + + + Hide Music โŒ˜H + + + Hide Others โ‡งโŒ˜H + + + + Quit Music โŒ˜Q + + + +

j

+ + File + + + New + + + Playlist โŒ˜N + + + Playlist from Selection โ‡งโŒ˜N + + + Smart Playlist โŒฅโŒ˜N + + Playlist Folder + Genius Playlist + + + + Open Stream URL โŒ˜U + + + Close Window โŒ˜W + + + + Library + + Update Cloud Library + Update Genius + + Organize Library + Export Library + + Import Playlist + Export Playlist + Show Duplicate Items + + Get Album Artwork + Get Track Names + + + + Import โŒ˜O + + Burn Playlist to Disc + + + Show in Finder โ‡งโŒ˜R{" "} + + Convert + + Page Setup + + Print โŒ˜P + + + + + Edit + + + Undo โŒ˜Z + + + Redo โ‡งโŒ˜Z + + + + Cut โŒ˜X + + + Copy โŒ˜C + + + Paste โŒ˜V + + + + Select All โŒ˜A + + + Deselect All โ‡งโŒ˜A + + + + Smart Dictation{" "} + + + + + + + + + Emoji & Symbols{" "} + + + + + + + + + + + View + + Show Playing Next + Show Lyrics + + + {isStatusBarVisible ? "Hide Status Bar" : "Show Status Bar"} + + + + {isSidebarVisible ? "Hide Sidebar" : "Show Sidebar"} + โŒ˜S + + + {isFullScreen ? "Exit Full Screen" : "Enter Full Screen"} + + + + + Account + + Server Status + {connectionStatus} + + router.push('/settings')}> + Settings + + + +
+ + + + + +
+
+ music +
+ +

+ a music player that doesn't (yet) play music +

+ +
+
+
+ + ) + } diff --git a/app/components/sidebar.tsx b/app/components/sidebar.tsx new file mode 100644 index 0000000..b31d47e --- /dev/null +++ b/app/components/sidebar.tsx @@ -0,0 +1,178 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { cn } from "@/lib/utils"; +import { usePathname } from 'next/navigation'; +import { Button } from "../../components/ui/button"; +import { ScrollArea } from "../../components/ui/scroll-area"; +import Link from "next/link"; +import { Playlist } from "@/lib/navidrome"; + +interface SidebarProps extends React.HTMLAttributes { + playlists: Playlist[]; +} + +export function Sidebar({ className, playlists }: SidebarProps) { + const isRoot = usePathname() === "/"; + const isBrowse = usePathname() === "/browse"; + const isAlbums = usePathname() === "/library/albums"; + const isArtists = usePathname() === "/library/artists"; + const isQueue = usePathname() === "/queue"; + const isHistory = usePathname() === "/history"; + const isSongs = usePathname() === "/library/songs"; const isPlaylists = usePathname() === "/library/playlists"; + + return ( +
+
+
+

+ Discover +

+
+ + + + + + + + + +
+
+
+
+

+ Library +

+
+ + + + + + + + + + + + + + + +
+
+
+
+
+ ); +} diff --git a/app/fonts/GeistMonoVF.woff b/app/fonts/GeistMonoVF.woff new file mode 100644 index 0000000..f2ae185 Binary files /dev/null and b/app/fonts/GeistMonoVF.woff differ diff --git a/app/fonts/GeistVF.woff b/app/fonts/GeistVF.woff new file mode 100644 index 0000000..1b62daa Binary files /dev/null and b/app/fonts/GeistVF.woff differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..2772c54 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,120 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + font-family: Arial, Helvetica, sans-serif; +} + +@layer utilities { + .text-balance { + text-wrap: balance; + } +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 0 0% 98%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 5.9% 10%; + --radius: 0.5rem; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + } + + .dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 0 0% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 224.3 76.3% 48%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + --hover: 240 27% 11%; + } +} + + + + +:focus-visible { outline-color: var(rgb(59 130 246)); } +::selection { background-color: var(rgb(59 130 246)); } +::marker { color: var(rgb(59 130 246)); } + + +::selection { + background: var(--primary); +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } + h1 { + @apply text-2xl; + @apply font-black; + } + h2 { + @apply text-xl; + @apply font-black; + } + h3 { + @apply text-lg; + @apply font-black; + } + h4 { + @apply text-base; + @apply font-black; + } + h5 { + @apply text-sm; + @apply font-black; + } + h6 { + @apply text-xs; + @apply font-black; + } + ul { + @apply list-disc; + @apply ml-9; + } + } \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..ab55a0e --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,70 @@ +import { SpeedInsights } from "@vercel/speed-insights/next"; +import React from 'react'; +import { Analytics } from "@vercel/analytics/react"; +import localFont from "next/font/local"; +import "./globals.css"; +import { AudioPlayerProvider } from "./components/AudioPlayerContext"; +import { NavidromeProvider } from "./components/NavidromeContext"; +import { Metadata } from "next"; +import type { Viewport } from 'next'; +import Ihateserverside from './components/ihateserverside'; + +export const viewport: Viewport = { + themeColor: 'black', +}; + +export const metadata: Metadata = { + title: { + template: 'offbrand spotify | %s', + default: 'offbrand spotify', + }, + description: 'a very awesome music streaming service', + robots: { + index: true, + follow: true, + nocache: true, + googleBot: { + index: true, + follow: false, + noimageindex: true, + 'max-video-preview': -1, + 'max-image-preview': 'large', + 'max-snippet': -1, + }, + }, +}; + +const geistSans = localFont({ + src: "./fonts/GeistVF.woff", + variable: "--font-geist-sans", + weight: "100 900", +}); +const geistMono = localFont({ + src: "./fonts/GeistMonoVF.woff", + variable: "--font-geist-mono", + weight: "100 900", +}); + +interface LayoutProps { + children: React.ReactNode; +} + +export default function Layout({ children }: LayoutProps) { + + + return ( + + + + + + + + {children} + + + + + + ); +} \ No newline at end of file diff --git a/app/library/albums/page.tsx b/app/library/albums/page.tsx new file mode 100644 index 0000000..520fd21 --- /dev/null +++ b/app/library/albums/page.tsx @@ -0,0 +1,65 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +'use client'; + +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import { useState, useEffect } from 'react'; +import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsContent } from "@/components/ui/tabs"; +import { AlbumArtwork } from '@/app/components/album-artwork'; +import { useNavidrome } from '@/app/components/NavidromeContext'; +import { Album } from '@/lib/navidrome'; +import Loading from '@/app/components/loading'; + +export default function Albumpage() { + const { albums, isLoading } = useNavidrome(); + const [sortedAlbums, setSortedAlbums] = useState([]); + + useEffect(() => { + if (albums.length > 0) { + // Sort albums alphabetically by name + const sorted = [...albums].sort((a, b) => a.name.localeCompare(b.name)); + setSortedAlbums(sorted); + } + }, [albums]); + + if (isLoading) { + return ; + } + + return ( +
+ + +
+
+

+ Albums +

+

+ All albums in your music library ({sortedAlbums.length} albums) +

+
+
+ +
+ +
+ {sortedAlbums.map((album) => ( + + ))} +
+ +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/library/artists/page.tsx b/app/library/artists/page.tsx new file mode 100644 index 0000000..da07b95 --- /dev/null +++ b/app/library/artists/page.tsx @@ -0,0 +1,63 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +'use client'; + +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import { useState, useEffect } from 'react'; +import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsContent } from "@/components/ui/tabs"; +import { ArtistIcon } from '@/app/components/artist-icon'; +import { useNavidrome } from '@/app/components/NavidromeContext'; +import { Artist } from '@/lib/navidrome'; +import Loading from '@/app/components/loading'; + +export default function ArtistPage() { + const { artists, isLoading } = useNavidrome(); + const [sortedArtists, setSortedArtists] = useState([]); + + useEffect(() => { + if (artists.length > 0) { + // Sort artists alphabetically by name + const sorted = [...artists].sort((a, b) => a.name.localeCompare(b.name)); + setSortedArtists(sorted); + } + }, [artists]); + + if (isLoading) { + return ; + } + + return ( +
+ + +
+
+

+ Artists +

+

+ All artists in your music library ({sortedArtists.length} artists) +

+
+
+ +
+ +
+ {sortedArtists.map((artist) => ( + + ))} +
+ +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/library/playlists/page.tsx b/app/library/playlists/page.tsx new file mode 100644 index 0000000..f54b6e0 --- /dev/null +++ b/app/library/playlists/page.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsContent } from "@/components/ui/tabs"; +import { useNavidrome } from '@/app/components/NavidromeContext'; +import Loading from '@/app/components/loading'; +import { Button } from '@/components/ui/button'; +import { PlusCircledIcon } from "@radix-ui/react-icons"; +import Link from 'next/link'; + +const PlaylistsPage: React.FC = () => { + const { playlists, isLoading, createPlaylist } = useNavidrome(); + + const handleCreatePlaylist = async () => { + const name = prompt('Enter playlist name:'); + if (name) { + try { + await createPlaylist(name); + } catch (error) { + console.error('Failed to create playlist:', error); + } + } + }; + + if (isLoading) { + return ; + } + + return ( +
+ + +
+
+

+ Playlists +

+

+ Your custom playlists ({playlists.length} playlists) +

+
+ +
+ +
+ +
+ {playlists.map((playlist) => ( + +
+
+
+ + + +
+
+

{playlist.name}

+

+ {playlist.songCount} songs +

+ {playlist.comment && ( +

+ {playlist.comment} +

+ )} +
+
+
+ + ))} +
+ +
+
+
+
+
+ ); +}; + +export default PlaylistsPage; \ No newline at end of file diff --git a/app/library/songs/page.tsx b/app/library/songs/page.tsx new file mode 100644 index 0000000..d5b1ae7 --- /dev/null +++ b/app/library/songs/page.tsx @@ -0,0 +1,294 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import Image from 'next/image'; +import { useNavidrome } from '@/app/components/NavidromeContext'; +import { useAudioPlayer } from '@/app/components/AudioPlayerContext'; +import { Song } from '@/lib/navidrome'; +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Search, Play, Plus, Clock, User, Disc } from 'lucide-react'; +import Loading from '@/app/components/loading'; +import { getNavidromeAPI } from '@/lib/navidrome'; + +type SortOption = 'title' | 'artist' | 'album' | 'year' | 'duration' | 'track'; +type SortDirection = 'asc' | 'desc'; + +export default function SongsPage() { + const { getAllSongs } = useNavidrome(); + const { playTrack, addToQueue, currentTrack } = useAudioPlayer(); + const [songs, setSongs] = useState([]); + const [filteredSongs, setFilteredSongs] = useState([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [sortBy, setSortBy] = useState('title'); + const [sortDirection, setSortDirection] = useState('asc'); + const api = getNavidromeAPI(); + + useEffect(() => { + const fetchSongs = async () => { + setLoading(true); + try { + const allSongs = await getAllSongs(); + setSongs(allSongs); + setFilteredSongs(allSongs); + } catch (error) { + console.error('Failed to fetch songs:', error); + } + setLoading(false); + }; + + fetchSongs(); + }, [getAllSongs]); + + useEffect(() => { + let filtered = songs; + + // Apply search filter + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + filtered = songs.filter(song => + song.title.toLowerCase().includes(query) || + song.artist.toLowerCase().includes(query) || + song.album.toLowerCase().includes(query) + ); + } + + // Apply sorting + filtered = [...filtered].sort((a, b) => { + let aValue: string | number; + let bValue: string | number; + + switch (sortBy) { + case 'title': + aValue = a.title.toLowerCase(); + bValue = b.title.toLowerCase(); + break; + case 'artist': + aValue = a.artist.toLowerCase(); + bValue = b.artist.toLowerCase(); + break; + case 'album': + aValue = a.album.toLowerCase(); + bValue = b.album.toLowerCase(); + break; + case 'year': + aValue = a.year || 0; + bValue = b.year || 0; + break; + case 'duration': + aValue = a.duration; + bValue = b.duration; + break; + case 'track': + aValue = a.track || 0; + bValue = b.track || 0; + break; + default: + aValue = a.title.toLowerCase(); + bValue = b.title.toLowerCase(); + } + + if (sortDirection === 'asc') { + return aValue < bValue ? -1 : aValue > bValue ? 1 : 0; + } else { + return aValue > bValue ? -1 : aValue < bValue ? 1 : 0; + } + }); + + setFilteredSongs(filtered); + }, [songs, searchQuery, sortBy, sortDirection]); + + const handlePlaySong = (song: Song) => { + const track = { + id: song.id, + name: song.title, + url: api.getStreamUrl(song.id), + artist: song.artist, + album: song.album, + duration: song.duration, + coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined, + albumId: song.albumId, + artistId: song.artistId + }; + + playTrack(track); + }; + + const handleAddToQueue = (song: Song) => { + const track = { + id: song.id, + name: song.title, + url: api.getStreamUrl(song.id), + artist: song.artist, + album: song.album, + duration: song.duration, + coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined, + albumId: song.albumId, + artistId: song.artistId + }; + + addToQueue(track); + }; + + const formatDuration = (seconds: number): string => { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; + }; + + const isCurrentlyPlaying = (song: Song): boolean => { + return currentTrack?.id === song.id; + }; + + if (loading) { + return ; + } + + return ( +
+
+ {/* Header */} +
+

Songs

+

+ {filteredSongs.length} of {songs.length} songs +

+
+ + {/* Search and Filters */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+
+ + +
+
+ + + + {/* Songs List */} + + {filteredSongs.length === 0 ? ( +
+

+ {searchQuery ? 'No songs found matching your search.' : 'No songs available.'} +

+
+ ) : ( +
+ {filteredSongs.map((song, index) => ( +
handlePlaySong(song)} + > + {/* Track Number / Play Indicator */} +
+ {isCurrentlyPlaying(song) ? ( +
+
+
+ ) : ( + {index + 1} + )} + +
+ + {/* Album Art */} +
+ {song.album} +
+ + {/* Song Info */} +
+
+

+ {song.title} +

+ {song.year && ( + + {song.year} + + )} +
+
+
+ + {song.artist} +
+
+ + {song.album} +
+
+
+ + {/* Duration */} +
+ + {formatDuration(song.duration)} +
+ + {/* Actions */} +
+ +
+
+ ))} +
+ )} + +
+
+ ); +} diff --git a/app/manifest.ts b/app/manifest.ts new file mode 100644 index 0000000..70b110d --- /dev/null +++ b/app/manifest.ts @@ -0,0 +1,44 @@ +import type { MetadataRoute } from 'next' + +export default function manifest(): MetadataRoute.Manifest { + return { + name: 'Offbrand Spotify', + short_name: 'Offbrand', + description: 'a very offbrand spotify clone', + start_url: '/', + categories: ["music", "entertainment"], + display_override: ['window-controls-overlay'], + display: 'standalone', + background_color: '#0f0f0f', + theme_color: '#0f0f0f', + icons: [ + { + src: '/favicon.ico', + type: 'image/x-icon', + sizes: '16x16 32x32' + }, + { + src: '/icon-192.png', + type: 'image/png', + sizes: '192x192' + }, + { + src: '/icon-512.png', + type: 'image/png', + sizes: '512x512' + }, + { + src: '/icon-192-maskable.png', + type: 'image/png', + sizes: '192x192', + purpose: 'maskable' + }, + { + src: './icon-512-maskable.png', + type: 'image/png', + sizes: '512x512', + purpose: 'maskable' + } + ], + } +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..88698e1 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { ScrollArea, ScrollBar } from '../components/ui/scroll-area'; +import { Separator } from '../components/ui/separator'; +import { Tabs, TabsContent } from '../components/ui/tabs'; +import { AlbumArtwork } from './components/album-artwork'; +import { useNavidrome } from './components/NavidromeContext'; +import { useEffect, useState } from 'react'; +import { Album } from '@/lib/navidrome'; + +export default function MusicPage() { + const { albums, isLoading, error } = useNavidrome(); + const [recentAlbums, setRecentAlbums] = useState([]); + const [newestAlbums, setNewestAlbums] = useState([]); + + useEffect(() => { + if (albums.length > 0) { + // Split albums into recent and newest for display + const recent = albums.slice(0, Math.ceil(albums.length / 2)); + const newest = albums.slice(Math.ceil(albums.length / 2)); + setRecentAlbums(recent); + setNewestAlbums(newest); + } + }, [albums]); + + if (error) { + return ( +
+
+

Connection Error

+

{error}

+

+ Please check your Navidrome server configuration. +

+
+
+ ); + } + + return ( +
+ <> + + +
+
+

+ Recently Added +

+

+ Latest additions to your music library. +

+
+
+ +
+ +
+ {isLoading ? ( + // Loading skeletons + Array.from({ length: 6 }).map((_, i) => ( +
+ )) + ) : ( + recentAlbums.map((album) => ( + + )) + )} +
+ + +
+
+

+ Your Library +

+

+ Albums from your music collection. +

+
+ +
+ +
+ {isLoading ? ( + // Loading skeletons + Array.from({ length: 10 }).map((_, i) => ( +
+ )) + ) : ( + newestAlbums.map((album) => ( + + )) + )} +
+ + +
+ + + +
+ ); +} \ No newline at end of file diff --git a/app/playlist/[id]/page.tsx b/app/playlist/[id]/page.tsx new file mode 100644 index 0000000..6927917 --- /dev/null +++ b/app/playlist/[id]/page.tsx @@ -0,0 +1,157 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams } from 'next/navigation'; +import Image from 'next/image'; +import { Playlist, Song } from '@/lib/navidrome'; +import { useNavidrome } from '@/app/components/NavidromeContext'; +import { useAudioPlayer } from '@/app/components/AudioPlayerContext'; +import { Play, Heart, Plus } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import Loading from "@/app/components/loading"; +import { Separator } from '@/components/ui/separator'; + +export default function PlaylistPage() { + const { id } = useParams(); + const [playlist, setPlaylist] = useState(null); + const [tracklist, setTracklist] = useState([]); + const [loading, setLoading] = useState(true); + const { getPlaylist } = useNavidrome(); + const { playTrack, addToQueue } = useAudioPlayer(); + + useEffect(() => { + const fetchPlaylist = async () => { + setLoading(true); + console.log(`Fetching playlist with id: ${id}`); + + try { + const playlistData = await getPlaylist(id as string); + setPlaylist(playlistData.playlist); + setTracklist(playlistData.songs); + console.log(`Playlist found: ${playlistData.playlist.name}`); + } catch (error) { + console.error('Failed to fetch playlist:', error); + } + + setLoading(false); + }; + + if (id) { + fetchPlaylist(); + } + }, [id, getPlaylist]); + + const handlePlayClick = (song: Song) => { + const track = { + id: song.id, + name: song.title, + url: '', // Will be set by the context + artist: song.artist, + album: song.album, + duration: song.duration, + coverArt: song.coverArt, + albumId: song.albumId, + artistId: song.artistId + }; + playTrack(track); + }; + + const handleAddToQueue = (song: Song) => { + const track = { + id: song.id, + name: song.title, + url: '', // Will be set by the context + artist: song.artist, + album: song.album, + duration: song.duration, + coverArt: song.coverArt, + albumId: song.albumId, + artistId: song.artistId + }; + addToQueue(track); + }; + + const formatDuration = (seconds: number) => { + const minutes = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60).toString().padStart(2, '0'); + return `${minutes}:${secs}`; + }; + + if (loading) { + return ; + } + + if (!playlist) { + return ( +
+
+

Playlist not found

+

The playlist you're looking for doesn't exist.

+
+
+ ); + } + + return ( +
+
+
+
+ +
+
+
+

{playlist.name}

+
+ {playlist.comment && ( +

{playlist.comment}

+ )} +
+

{playlist.songCount} songs โ€ข {formatDuration(playlist.duration || 0)}

+ {playlist.public !== undefined && ( +

{playlist.public ? 'Public' : 'Private'} playlist

+ )} +
+
+
+
+ + {tracklist.length > 0 ? ( + tracklist.map((song, index) => ( +
handlePlayClick(song)}> +
+
{index + 1}
+
+

+ {song.title} +

+

+ {song.artist} +

+
+
+
+

{formatDuration(song.duration)}

+ +
+
+ )) + ) : ( +
+

This playlist is empty

+
+ )} +
+
+
+ ); +} diff --git a/app/queue/page.tsx b/app/queue/page.tsx new file mode 100644 index 0000000..5593332 --- /dev/null +++ b/app/queue/page.tsx @@ -0,0 +1,102 @@ +'use client'; + +import React from 'react'; +import Image from 'next/image'; +import { useAudioPlayer } from '@/app/components/AudioPlayerContext'; + +const QueuePage: React.FC = () => { + const { queue, currentTrack, removeTrackFromQueue, clearQueue, skipToTrackInQueue } = useAudioPlayer(); + + return ( +
+
+
+

Queue

+

Click on a track to skip to it

+
+ +
+ + {/* Currently Playing */} + {currentTrack && ( +
+

Now Playing

+
+
+ {currentTrack.name} +
+

{currentTrack.name}

+

{currentTrack.artist}

+

{currentTrack.album}

+
+
+ {Math.floor(currentTrack.duration / 60)}:{(currentTrack.duration % 60).toString().padStart(2, '0')} +
+
+
+
+ )} + + {/* Queue */} +
+

Up Next

+ {queue.length === 0 ? ( +

No tracks in the queue

+ ) : ( +
+ {queue.map((track, index) => ( +
skipToTrackInQueue(index)} + > +
+ {index + 1} +
+ {track.name} +
+

{track.name}

+

{track.artist}

+
+
+
+ {Math.floor(track.duration / 60)}:{(track.duration % 60).toString().padStart(2, '0')} +
+ +
+
+ ))} +
+ )} +
+
+ ); +}; + +export default QueuePage; \ No newline at end of file diff --git a/app/settings/page.tsx b/app/settings/page.tsx new file mode 100644 index 0000000..d902fd8 --- /dev/null +++ b/app/settings/page.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Label } from '@/components/ui/label'; +import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'; + +const SettingsPage = () => { + return ( +
+ + +
+ ); +}; + +export default SettingsPage; \ No newline at end of file diff --git a/colors.txt b/colors.txt new file mode 100644 index 0000000..8181a94 --- /dev/null +++ b/colors.txt @@ -0,0 +1,12 @@ +border is located at * +hsl(214.3deg 3.81% 25%) + +background color in sticky top-0 +hsl(0deg 0% 5.86%) + +.text muted foreground +hsl(0deg 0% 58.47%) + +change in body color +hsl(0 0% 100%) + diff --git a/components.json b/components.json new file mode 100644 index 0000000..4a7d1f5 --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..5afd41d --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..51e507b --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..65d4fcd --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,57 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..cabfbfc --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,76 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx new file mode 100644 index 0000000..f7257a6 --- /dev/null +++ b/components/ui/context-menu.tsx @@ -0,0 +1,200 @@ +"use client" + +import * as React from "react" +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ContextMenu = ContextMenuPrimitive.Root + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger + +const ContextMenuGroup = ContextMenuPrimitive.Group + +const ContextMenuPortal = ContextMenuPrimitive.Portal + +const ContextMenuSub = ContextMenuPrimitive.Sub + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +ContextMenuCheckboxItem.displayName = + ContextMenuPrimitive.CheckboxItem.displayName + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName + +const ContextMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName + +const ContextMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +ContextMenuShortcut.displayName = "ContextMenuShortcut" + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx new file mode 100644 index 0000000..1647513 --- /dev/null +++ b/components/ui/dialog.tsx @@ -0,0 +1,122 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/components/ui/form.tsx b/components/ui/form.tsx new file mode 100644 index 0000000..b6daa65 --- /dev/null +++ b/components/ui/form.tsx @@ -0,0 +1,178 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +