Merge pull request #40 from sillyangel/dev
dev to main 2025.07.31
@@ -1 +1 @@
|
||||
NEXT_PUBLIC_COMMIT_SHA=35febc5
|
||||
NEXT_PUBLIC_COMMIT_SHA=0c32c05
|
||||
|
||||
6
.github/workflows/release.yml
vendored
@@ -81,12 +81,6 @@ jobs:
|
||||
cache-to: |
|
||||
type=gha,mode=max,scope=deps-only
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v1
|
||||
with:
|
||||
subject-name: ${{ env.IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
# - name: Docker Hub Description
|
||||
# uses: peter-evans/dockerhub-description@v4
|
||||
|
||||
38
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debug: Next.js Development",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/node_modules/.bin/next",
|
||||
"args": ["dev"],
|
||||
"console": "integratedTerminal",
|
||||
"env": {
|
||||
"NODE_ENV": "development"
|
||||
},
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"resolveSourceMapLocations": [
|
||||
"${workspaceFolder}/**",
|
||||
"!**/node_modules/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Debug: Next.js Production",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/node_modules/.bin/next",
|
||||
"args": ["start"],
|
||||
"console": "integratedTerminal",
|
||||
"env": {
|
||||
"NODE_ENV": "production"
|
||||
},
|
||||
"preLaunchTask": "Build: Production Build Only",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["run", "start"],
|
||||
"skipFiles": ["<node_internals>/**"]
|
||||
}
|
||||
]
|
||||
}
|
||||
114
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Dev: Start Development Server",
|
||||
"type": "shell",
|
||||
"command": "pnpm",
|
||||
"args": [
|
||||
"run",
|
||||
"dev"
|
||||
],
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"isBackground": true,
|
||||
"problemMatcher": [
|
||||
"$tsc-watch"
|
||||
],
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "new",
|
||||
"showReuseMessage": true,
|
||||
"clear": false
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Prod: Build and Start Production",
|
||||
"type": "shell",
|
||||
"command": "bash",
|
||||
"args": [
|
||||
"-c",
|
||||
"pnpm run build && pnpm run start"
|
||||
],
|
||||
"group": "build",
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": true,
|
||||
"panel": "new",
|
||||
"showReuseMessage": true,
|
||||
"clear": true
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
},
|
||||
"problemMatcher": ["$tsc"],
|
||||
"dependsOrder": "sequence"
|
||||
},
|
||||
{
|
||||
"label": "Debug: Development with Debug Info",
|
||||
"type": "shell",
|
||||
"command": "pnpm",
|
||||
"args": [
|
||||
"run",
|
||||
"dev"
|
||||
],
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": false
|
||||
},
|
||||
"isBackground": true,
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "new",
|
||||
"showReuseMessage": true,
|
||||
"clear": false
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
"NODE_ENV": "development",
|
||||
"DEBUG": "*",
|
||||
"NEXT_TELEMETRY_DISABLED": "1"
|
||||
}
|
||||
},
|
||||
"problemMatcher": ["$tsc-watch"]
|
||||
},
|
||||
{
|
||||
"label": "Build: Production Build Only",
|
||||
"type": "shell",
|
||||
"command": "pnpm",
|
||||
"args": [
|
||||
"run",
|
||||
"build"
|
||||
],
|
||||
"group": "build",
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": true,
|
||||
"panel": "new",
|
||||
"showReuseMessage": true,
|
||||
"clear": true
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
},
|
||||
"problemMatcher": ["$tsc"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
# GitHub Actions Docker Publishing Setup
|
||||
|
||||
This repository includes a GitHub Actions workflow that automatically builds and publishes Docker images to Docker Hub.
|
||||
|
||||
## Workflow Overview
|
||||
|
||||
The workflow (`/.github/workflows/publish-docker.yml`) automatically:
|
||||
|
||||
1. **Builds** the Docker image using multi-platform support (AMD64 and ARM64)
|
||||
2. **Publishes** to `sillyangel/mice`
|
||||
3. **Tags** images appropriately based on git refs
|
||||
4. **Caches** layers for faster subsequent builds
|
||||
5. **Generates** build provenance attestations for security
|
||||
|
||||
## Trigger Conditions
|
||||
|
||||
The workflow runs on:
|
||||
|
||||
- **Push to main/master branch** → Creates `latest` tag
|
||||
- **Push tags** (e.g., `2025.07.02`) → Creates date-based version tags
|
||||
- **Pull requests** → Creates PR-specific tags for testing
|
||||
- **Manual dispatch** → Can be triggered manually from GitHub UI
|
||||
|
||||
## Image Tags Generated
|
||||
|
||||
Based on different triggers, the workflow creates these tags:
|
||||
|
||||
### Main Branch Push
|
||||
|
||||
- `sillyangel/mice:latest`
|
||||
|
||||
### Tag Push (e.g., `2025.07.02`)
|
||||
|
||||
- `sillyangel/mice:2025.07.02`
|
||||
- `sillyangel/mice:latest`
|
||||
|
||||
### Pull Request
|
||||
|
||||
- `sillyangel/mice:pr-123`
|
||||
|
||||
## Multi-Platform Support
|
||||
|
||||
The workflow builds for multiple architectures:
|
||||
|
||||
- `linux/amd64` (Intel/AMD 64-bit)
|
||||
- `linux/arm64` (ARM 64-bit, Apple Silicon, etc.)
|
||||
|
||||
## Usage After Setup
|
||||
|
||||
Once the workflow is set up:
|
||||
|
||||
1. **Push to main** → New `latest` image published
|
||||
2. **Create a release** → Versioned images published
|
||||
3. **Users can pull**: `docker pull sillyangel/mice:latest`
|
||||
|
||||
## Manual Image Building
|
||||
|
||||
You can also build and push manually:
|
||||
|
||||
```bash
|
||||
# Build for multiple platforms
|
||||
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
-t sillyangel/mice:latest \
|
||||
--push .
|
||||
|
||||
# Login first (if needed)
|
||||
echo $DOCKERHUB_TOKEN | docker login -u USERNAME --password-stdin
|
||||
```
|
||||
@@ -10,9 +10,9 @@ import Link from 'next/link';
|
||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext'
|
||||
import Loading from "@/app/components/loading";
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { getNavidromeAPI } from '@/lib/navidrome';
|
||||
import { useFavoriteAlbums } from '@/hooks/use-favorite-albums';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
|
||||
export default function AlbumPage() {
|
||||
const { id } = useParams();
|
||||
@@ -24,6 +24,7 @@ export default function AlbumPage() {
|
||||
const { getAlbum, starItem, unstarItem } = useNavidrome();
|
||||
const { playTrack, addAlbumToQueue, playAlbum, playAlbumFromTrack, currentTrack } = useAudioPlayer();
|
||||
const { isFavoriteAlbum, toggleFavoriteAlbum } = useFavoriteAlbums();
|
||||
const isMobile = useIsMobile();
|
||||
const api = getNavidromeAPI();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -119,110 +120,157 @@ export default function AlbumPage() {
|
||||
const seconds = duration % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
// Get cover art URL with proper fallback
|
||||
const coverArtUrl = album.coverArt && api
|
||||
? api.getCoverArtUrl(album.coverArt, 300)
|
||||
: '/default-user.jpg';
|
||||
|
||||
// Dynamic cover art URLs based on image size
|
||||
const getMobileCoverArtUrl = () => {
|
||||
return album.coverArt && api
|
||||
? api.getCoverArtUrl(album.coverArt, 280)
|
||||
: '/default-user.jpg';
|
||||
};
|
||||
|
||||
const getDesktopCoverArtUrl = () => {
|
||||
return album.coverArt && api
|
||||
? api.getCoverArtUrl(album.coverArt, 300)
|
||||
: '/default-user.jpg';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full px-4 py-6 lg:px-8">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-6">
|
||||
<Image
|
||||
src={coverArtUrl}
|
||||
alt={album.name}
|
||||
width={300}
|
||||
height={300}
|
||||
className="rounded-md"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-4">
|
||||
<p className="text-3xl font-semibold tracking-tight">{album.name}</p>
|
||||
<Button onClick={handleStar} variant="ghost" title={isStarred ? "Unstar album" : "Star album"}>
|
||||
<Heart className={isStarred ? 'text-primary' : 'text-gray-500'} fill={isStarred ? 'var(--primary)' : ""}/>
|
||||
</Button>
|
||||
{isMobile ? (
|
||||
/* Mobile Layout */
|
||||
<div className="space-y-6">
|
||||
{/* Album Cover - Centered */}
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src={getMobileCoverArtUrl()}
|
||||
alt={album.name}
|
||||
width={280}
|
||||
height={280}
|
||||
className="rounded-md shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
<Link href={`/artist/${album.artistId}`}>
|
||||
<p className="text-xl text-primary mt-0 mb-4 underline">{album.artist}</p>
|
||||
</Link>
|
||||
<Button className="px-5" onClick={() => playAlbum(album.id)}>
|
||||
Play
|
||||
</Button>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>{album.genre} • {album.year}</p>
|
||||
<p>{album.songCount} songs, {formatDuration(album.duration)}</p>
|
||||
|
||||
{/* Album Info and Controls */}
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
{/* Left side - Album Info */}
|
||||
<div className="flex-1 space-y-1">
|
||||
<h1 className="text-2xl font-bold text-left">{album.name}</h1>
|
||||
<Link href={`/artist/${album.artistId}`}>
|
||||
<p className="text-lg text-primary underline text-left">{album.artist}</p>
|
||||
</Link>
|
||||
<p className="text-sm text-muted-foreground text-left">{album.genre} • {album.year}</p>
|
||||
<p className="text-sm text-muted-foreground text-left">{album.songCount} songs, {formatDuration(album.duration)}</p>
|
||||
</div>
|
||||
|
||||
{/* Right side - Controls */}
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Button
|
||||
className="w-12 h-12 rounded-full p-0"
|
||||
onClick={() => playAlbum(album.id)}
|
||||
title="Play Album"
|
||||
>
|
||||
<Play className="w-6 h-6" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Desktop Layout */
|
||||
<div className="flex items-start gap-6">
|
||||
<Image
|
||||
src={getDesktopCoverArtUrl()}
|
||||
alt={album.name}
|
||||
width={300}
|
||||
height={300}
|
||||
className="rounded-md"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-4">
|
||||
<p className="text-3xl font-semibold tracking-tight">{album.name}</p>
|
||||
<Button onClick={handleStar} variant="ghost" title={isStarred ? "Unstar album" : "Star album"}>
|
||||
<Heart className={isStarred ? 'text-primary' : 'text-gray-500'} fill={isStarred ? 'var(--primary)' : ""}/>
|
||||
</Button>
|
||||
</div>
|
||||
<Link href={`/artist/${album.artistId}`}>
|
||||
<p className="text-xl text-primary mt-0 mb-4 underline">{album.artist}</p>
|
||||
</Link>
|
||||
<Button className="px-5" onClick={() => playAlbum(album.id)}>
|
||||
Play
|
||||
</Button>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>{album.genre} • {album.year}</p>
|
||||
<p>{album.songCount} songs, {formatDuration(album.duration)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<Separator />
|
||||
|
||||
<ScrollArea className="h-[calc(100vh-500px)]">
|
||||
{tracklist.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">No tracks available.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{tracklist.map((song, index) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors`}
|
||||
onClick={() => handlePlayClick(song)}
|
||||
>
|
||||
{/* Track Number / Play Indicator */}
|
||||
<div className="w-8 text-center text-sm text-muted-foreground mr-3">
|
||||
<>
|
||||
<span className="group-hover:hidden">{song.track || index + 1}</span>
|
||||
<Play className="w-4 h-4 mx-auto hidden group-hover:block" />
|
||||
</>
|
||||
</div>
|
||||
{tracklist.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">No tracks available.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 pb-32">
|
||||
{tracklist.map((song, index) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors`}
|
||||
onClick={() => handlePlayClick(song)}
|
||||
>
|
||||
{/* Track Number / Play Indicator */}
|
||||
<div className="w-8 text-center text-sm text-muted-foreground mr-3">
|
||||
<>
|
||||
<span className="group-hover:hidden">{song.track || index + 1}</span>
|
||||
<Play className="w-4 h-4 mx-auto hidden group-hover:block" />
|
||||
</>
|
||||
</div>
|
||||
|
||||
{/* Song Info */}
|
||||
<div className="flex-1 min-w-0 mr-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className={`font-semibold truncate ${
|
||||
isCurrentlyPlaying(song) ? 'text-primary' : ''
|
||||
}`}>
|
||||
{song.title}
|
||||
</p>
|
||||
{/* Song Info */}
|
||||
<div className="flex-1 min-w-0 mr-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className={`font-semibold truncate ${
|
||||
isCurrentlyPlaying(song) ? 'text-primary' : ''
|
||||
}`}>
|
||||
{song.title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="truncate">{song.artist}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="truncate">{song.artist}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="flex items-center text-sm text-muted-foreground mr-4">
|
||||
{formatDuration(song.duration)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center space-x-2 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSongStar(song);
|
||||
}}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Heart
|
||||
className={`w-4 h-4 ${starredSongs.has(song.id) ? 'text-primary' : 'text-gray-500'}`}
|
||||
fill={starredSongs.has(song.id) ? 'var(--primary)' : 'none'}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="flex items-center text-sm text-muted-foreground mr-4">
|
||||
{formatDuration(song.duration)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center space-x-2 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSongStar(song);
|
||||
}}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Heart
|
||||
className={`w-4 h-4 ${starredSongs.has(song.id) ? 'text-primary' : 'text-gray-500'}`}
|
||||
fill={starredSongs.has(song.id) ? 'var(--primary)' : 'none'}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||
import Loading from '@/app/components/loading';
|
||||
import { getNavidromeAPI } from '@/lib/navidrome';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
|
||||
export default function ArtistPage() {
|
||||
const { artist: artistId } = useParams();
|
||||
@@ -27,6 +28,7 @@ export default function ArtistPage() {
|
||||
const { getArtist, starItem, unstarItem } = useNavidrome();
|
||||
const { playArtist } = useAudioPlayer();
|
||||
const { toast } = useToast();
|
||||
const isMobile = useIsMobile();
|
||||
const api = getNavidromeAPI();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -103,7 +105,7 @@ export default function ArtistPage() {
|
||||
}
|
||||
// Get artist image URL with proper fallback
|
||||
const artistImageUrl = artist.coverArt && api
|
||||
? api.getCoverArtUrl(artist.coverArt, 300)
|
||||
? api.getCoverArtUrl(artist.coverArt, 1200)
|
||||
: '/default-user.jpg';
|
||||
|
||||
return (
|
||||
@@ -152,7 +154,7 @@ export default function ArtistPage() {
|
||||
<ArtistBio artistName={artist.name} />
|
||||
|
||||
{/* Popular Songs Section */}
|
||||
{popularSongs.length > 0 && (
|
||||
{!isMobile && popularSongs.length > 0 && (
|
||||
<PopularSongs songs={popularSongs} artistName={artist.name} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -10,10 +10,19 @@ import { Progress } from '@/components/ui/progress';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useLastFmScrobbler } from '@/hooks/use-lastfm-scrobbler';
|
||||
import { useStandaloneLastFm } from '@/hooks/use-standalone-lastfm';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
|
||||
export const AudioPlayer: React.FC = () => {
|
||||
const { currentTrack, playPreviousTrack, addToQueue, playNextTrack, clearQueue, queue, toggleShuffle, shuffle, toggleCurrentTrackStar } = useAudioPlayer();
|
||||
const router = useRouter();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Swipe gesture state for mobile
|
||||
const [touchStart, setTouchStart] = useState<number | null>(null);
|
||||
const [touchEnd, setTouchEnd] = useState<number | null>(null);
|
||||
|
||||
// Minimum swipe distance (in px)
|
||||
const minSwipeDistance = 50;
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const preloadAudioRef = useRef<HTMLAudioElement>(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
@@ -23,9 +32,36 @@ export const AudioPlayer: React.FC = () => {
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||
const [audioInitialized, setAudioInitialized] = useState(false);
|
||||
const audioCurrent = audioRef.current;
|
||||
const { toast } = useToast();
|
||||
|
||||
// Swipe gesture handlers for mobile
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
setTouchEnd(null);
|
||||
setTouchStart(e.targetTouches[0].clientX);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
setTouchEnd(e.targetTouches[0].clientX);
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (!touchStart || !touchEnd) return;
|
||||
|
||||
const distance = touchStart - touchEnd;
|
||||
const isLeftSwipe = distance > minSwipeDistance;
|
||||
const isRightSwipe = distance < -minSwipeDistance;
|
||||
|
||||
if (isLeftSwipe) {
|
||||
// Swipe left -> next track
|
||||
playNextTrack();
|
||||
} else if (isRightSwipe) {
|
||||
// Swipe right -> previous track
|
||||
playPreviousTrack();
|
||||
}
|
||||
};
|
||||
|
||||
// Last.fm scrobbler integration (Navidrome)
|
||||
const {
|
||||
onTrackStart: navidromeOnTrackStart,
|
||||
@@ -91,6 +127,89 @@ export const AudioPlayer: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile-specific audio initialization
|
||||
if (isMobile) {
|
||||
// Detect if running as PWA
|
||||
const isPWA = window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||||
|
||||
console.log('🔍 Audio initialization debug:', {
|
||||
isMobile,
|
||||
isPWA,
|
||||
audioInitialized,
|
||||
userAgent: navigator.userAgent
|
||||
});
|
||||
|
||||
// Add a document click listener to initialize audio context on first user interaction
|
||||
const initializeAudioOnMobile = async () => {
|
||||
if (!audioInitialized) {
|
||||
try {
|
||||
console.log('🎵 Initializing mobile audio context...', { isPWA });
|
||||
|
||||
const AudioContextClass = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (AudioContextClass) {
|
||||
const audioContext = new AudioContextClass();
|
||||
console.log('Audio context state:', audioContext.state);
|
||||
|
||||
if (audioContext.state === 'suspended') {
|
||||
console.log('Resuming suspended audio context...');
|
||||
await audioContext.resume();
|
||||
console.log('Audio context resumed, new state:', audioContext.state);
|
||||
}
|
||||
|
||||
// For PWA, we need to explicitly unlock audio
|
||||
if (isPWA && audioRef.current) {
|
||||
console.log('PWA detected, performing audio unlock...');
|
||||
|
||||
// Create a silent audio buffer to unlock audio
|
||||
const buffer = audioContext.createBuffer(1, 1, 22050);
|
||||
const source = audioContext.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(audioContext.destination);
|
||||
source.start(0);
|
||||
|
||||
// Also try to load the audio element
|
||||
try {
|
||||
audioRef.current.volume = 0;
|
||||
const playPromise = audioRef.current.play();
|
||||
if (playPromise) {
|
||||
await playPromise;
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
}
|
||||
audioRef.current.volume = volume;
|
||||
console.log('✅ PWA audio unlock successful');
|
||||
} catch (unlockError) {
|
||||
console.log('⚠️ PWA audio unlock failed:', unlockError);
|
||||
}
|
||||
}
|
||||
|
||||
setAudioInitialized(true);
|
||||
console.log('✅ Mobile audio context initialized successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('❌ Mobile audio context initialization failed:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for any user interaction to initialize audio
|
||||
const handleFirstUserInteraction = () => {
|
||||
console.log('🎯 First user interaction detected, initializing audio...');
|
||||
initializeAudioOnMobile();
|
||||
document.removeEventListener('touchstart', handleFirstUserInteraction);
|
||||
document.removeEventListener('click', handleFirstUserInteraction);
|
||||
};
|
||||
|
||||
document.addEventListener('touchstart', handleFirstUserInteraction, { passive: true });
|
||||
document.addEventListener('click', handleFirstUserInteraction);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('touchstart', handleFirstUserInteraction);
|
||||
document.removeEventListener('click', handleFirstUserInteraction);
|
||||
};
|
||||
}
|
||||
|
||||
// Clean up old localStorage entries with track IDs
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
@@ -100,7 +219,7 @@ export const AudioPlayer: React.FC = () => {
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach(key => localStorage.removeItem(key));
|
||||
}, []);
|
||||
}, [isMobile, audioInitialized, volume]);
|
||||
|
||||
// Apply volume to audio element when volume changes
|
||||
useEffect(() => {
|
||||
@@ -129,8 +248,76 @@ export const AudioPlayer: React.FC = () => {
|
||||
// Always clear current track time when changing tracks
|
||||
localStorage.removeItem('navidrome-current-track-time');
|
||||
|
||||
console.log('🔄 Setting audio source:', currentTrack.url);
|
||||
|
||||
// Debug: Check if URL is valid
|
||||
if (!currentTrack.url || currentTrack.url === 'undefined' || currentTrack.url === '') {
|
||||
console.error('❌ Invalid audio URL:', currentTrack.url);
|
||||
return;
|
||||
}
|
||||
|
||||
// Debug: Log current audio element state
|
||||
console.log('🔍 Audio element state before loading:', {
|
||||
src: audioCurrent.src,
|
||||
readyState: audioCurrent.readyState,
|
||||
networkState: audioCurrent.networkState,
|
||||
crossOrigin: audioCurrent.crossOrigin,
|
||||
canPlayType_mp3: audioCurrent.canPlayType('audio/mpeg'),
|
||||
canPlayType_mp4: audioCurrent.canPlayType('audio/mp4'),
|
||||
canPlayType_webm: audioCurrent.canPlayType('audio/webm'),
|
||||
canPlayType_ogg: audioCurrent.canPlayType('audio/ogg'),
|
||||
canPlayType_flac: audioCurrent.canPlayType('audio/flac'),
|
||||
canPlayType_wav: audioCurrent.canPlayType('audio/wav')
|
||||
});
|
||||
|
||||
// Clear any previous error handlers
|
||||
audioCurrent.onerror = null;
|
||||
audioCurrent.onloadstart = null;
|
||||
audioCurrent.oncanplay = null;
|
||||
|
||||
// Simple error handling
|
||||
audioCurrent.onerror = (e) => {
|
||||
const event = e as Event;
|
||||
const error = event.target as HTMLAudioElement;
|
||||
console.error('❌ Audio element error:', {
|
||||
error: error.error,
|
||||
networkState: error.networkState,
|
||||
readyState: error.readyState,
|
||||
src: error.src
|
||||
});
|
||||
};
|
||||
|
||||
audioCurrent.onloadstart = () => {
|
||||
console.log('📥 Audio load started');
|
||||
};
|
||||
|
||||
audioCurrent.oncanplay = () => {
|
||||
console.log('✅ Audio can play');
|
||||
};
|
||||
|
||||
// Set source without any CORS configuration
|
||||
audioCurrent.removeAttribute('crossorigin');
|
||||
audioCurrent.src = currentTrack.url;
|
||||
|
||||
// Force load and log state after setting source
|
||||
audioCurrent.load();
|
||||
|
||||
// Log state after load
|
||||
setTimeout(() => {
|
||||
console.log('🔍 Audio element state after load:', {
|
||||
src: audioCurrent.src,
|
||||
readyState: audioCurrent.readyState,
|
||||
networkState: audioCurrent.networkState,
|
||||
error: audioCurrent.error,
|
||||
duration: audioCurrent.duration
|
||||
});
|
||||
}, 100);
|
||||
|
||||
// For iOS, ensure audio element is properly loaded
|
||||
if (isMobile) {
|
||||
audioCurrent.load();
|
||||
}
|
||||
|
||||
// Notify scrobbler about new track
|
||||
onTrackStart(currentTrack);
|
||||
|
||||
@@ -157,21 +344,31 @@ export const AudioPlayer: React.FC = () => {
|
||||
localStorage.removeItem('navidrome-current-track-time');
|
||||
}
|
||||
|
||||
// Auto-play only if the track has the autoPlay flag
|
||||
if (currentTrack.autoPlay) {
|
||||
audioCurrent.play().then(() => {
|
||||
// Auto-play only if the track has the autoPlay flag and audio is initialized
|
||||
if (currentTrack.autoPlay && (!isMobile || audioInitialized)) {
|
||||
// Add a small delay for iOS compatibility
|
||||
const playPromise = isMobile ?
|
||||
new Promise(resolve => setTimeout(resolve, 100)).then(() => audioCurrent.play()) :
|
||||
audioCurrent.play();
|
||||
|
||||
playPromise.then(() => {
|
||||
setIsPlaying(true);
|
||||
// Notify scrobbler about play
|
||||
onTrackPlay(currentTrack);
|
||||
}).catch((error) => {
|
||||
console.error('Failed to auto-play:', error);
|
||||
setIsPlaying(false);
|
||||
|
||||
// On iOS, auto-play might fail - that's normal
|
||||
if (isMobile) {
|
||||
console.log('Auto-play failed on mobile - user interaction required');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}
|
||||
}, [currentTrack, onTrackStart, onTrackPlay]);
|
||||
}, [currentTrack, onTrackStart, onTrackPlay, isMobile, audioInitialized]);
|
||||
|
||||
useEffect(() => {
|
||||
const audioCurrent = audioRef.current;
|
||||
@@ -245,69 +442,131 @@ export const AudioPlayer: React.FC = () => {
|
||||
};
|
||||
}, [playNextTrack, currentTrack, onTrackProgress, onTrackEnd, onTrackPlay, onTrackPause]);
|
||||
|
||||
// Media Session API integration
|
||||
// Media Session API integration - Enhanced for mobile
|
||||
useEffect(() => {
|
||||
if (!isClient || !currentTrack || !('mediaSession' in navigator)) return;
|
||||
if (!isClient || !currentTrack) return;
|
||||
|
||||
// Check if MediaSession is supported
|
||||
if (!('mediaSession' in navigator)) {
|
||||
console.log('MediaSession API not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set metadata
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: currentTrack.name,
|
||||
artist: currentTrack.artist,
|
||||
album: currentTrack.album,
|
||||
artwork: currentTrack.coverArt ? [
|
||||
{ src: currentTrack.coverArt, sizes: '512x512', type: 'image/jpeg' }
|
||||
] : undefined,
|
||||
});
|
||||
try {
|
||||
// Set metadata
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: currentTrack.name,
|
||||
artist: currentTrack.artist,
|
||||
album: currentTrack.album,
|
||||
artwork: currentTrack.coverArt ? [
|
||||
{ src: currentTrack.coverArt, sizes: '96x96', type: 'image/jpeg' },
|
||||
{ src: currentTrack.coverArt, sizes: '128x128', type: 'image/jpeg' },
|
||||
{ src: currentTrack.coverArt, sizes: '192x192', type: 'image/jpeg' },
|
||||
{ src: currentTrack.coverArt, sizes: '256x256', type: 'image/jpeg' },
|
||||
{ src: currentTrack.coverArt, sizes: '384x384', type: 'image/jpeg' },
|
||||
{ src: currentTrack.coverArt, sizes: '512x512', type: 'image/jpeg' }
|
||||
] : [
|
||||
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' }
|
||||
],
|
||||
});
|
||||
|
||||
// Set playback state
|
||||
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
|
||||
// Set playback state
|
||||
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
|
||||
|
||||
// Set action handlers
|
||||
navigator.mediaSession.setActionHandler('play', () => {
|
||||
const audioCurrent = audioRef.current;
|
||||
if (audioCurrent && currentTrack) {
|
||||
audioCurrent.play();
|
||||
setIsPlaying(true);
|
||||
onTrackPlay(currentTrack);
|
||||
// Set action handlers with error handling
|
||||
navigator.mediaSession.setActionHandler('play', () => {
|
||||
const audioCurrent = audioRef.current;
|
||||
if (audioCurrent && currentTrack) {
|
||||
audioCurrent.play().then(() => {
|
||||
setIsPlaying(true);
|
||||
onTrackPlay(currentTrack);
|
||||
}).catch(console.error);
|
||||
}
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler('pause', () => {
|
||||
const audioCurrent = audioRef.current;
|
||||
if (audioCurrent && currentTrack) {
|
||||
audioCurrent.pause();
|
||||
setIsPlaying(false);
|
||||
onTrackPause(audioCurrent.currentTime);
|
||||
}
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler('previoustrack', () => {
|
||||
playPreviousTrack();
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler('nexttrack', () => {
|
||||
playNextTrack();
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler('seekto', (details) => {
|
||||
const audioCurrent = audioRef.current;
|
||||
if (audioCurrent && details.seekTime !== undefined) {
|
||||
audioCurrent.currentTime = details.seekTime;
|
||||
}
|
||||
});
|
||||
|
||||
// Add togglefavorite action for iOS
|
||||
try {
|
||||
// togglefavorite is an iOS-specific action that may not be in TypeScript definitions
|
||||
const mediaSession = navigator.mediaSession as MediaSession & {
|
||||
setActionHandler(action: 'togglefavorite', handler: MediaSessionActionHandler | null): void;
|
||||
};
|
||||
mediaSession.setActionHandler('togglefavorite', () => {
|
||||
toggleCurrentTrackStar();
|
||||
});
|
||||
} catch (error) {
|
||||
// togglefavorite might not be supported on all platforms
|
||||
console.log('togglefavorite action not supported:', error);
|
||||
}
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler('pause', () => {
|
||||
const audioCurrent = audioRef.current;
|
||||
if (audioCurrent && currentTrack) {
|
||||
audioCurrent.pause();
|
||||
setIsPlaying(false);
|
||||
onTrackPause(audioCurrent.currentTime);
|
||||
}
|
||||
});
|
||||
// Update position state for better scrubbing support
|
||||
const updatePositionState = () => {
|
||||
const audioCurrent = audioRef.current;
|
||||
if (audioCurrent && currentTrack && 'setPositionState' in navigator.mediaSession) {
|
||||
try {
|
||||
navigator.mediaSession.setPositionState({
|
||||
duration: audioCurrent.duration || 0,
|
||||
playbackRate: audioCurrent.playbackRate || 1.0,
|
||||
position: audioCurrent.currentTime || 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('Position state update failed:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
navigator.mediaSession.setActionHandler('previoustrack', () => {
|
||||
playPreviousTrack();
|
||||
});
|
||||
// Update position state periodically
|
||||
const positionInterval = setInterval(updatePositionState, 1000);
|
||||
|
||||
navigator.mediaSession.setActionHandler('nexttrack', () => {
|
||||
playNextTrack();
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler('seekto', (details) => {
|
||||
const audioCurrent = audioRef.current;
|
||||
if (audioCurrent && details.seekTime !== undefined) {
|
||||
audioCurrent.currentTime = details.seekTime;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.setActionHandler('play', null);
|
||||
navigator.mediaSession.setActionHandler('pause', null);
|
||||
navigator.mediaSession.setActionHandler('previoustrack', null);
|
||||
navigator.mediaSession.setActionHandler('nexttrack', null);
|
||||
navigator.mediaSession.setActionHandler('seekto', null);
|
||||
}
|
||||
};
|
||||
}, [currentTrack, isPlaying, isClient, playPreviousTrack, playNextTrack, onTrackPlay, onTrackPause]);
|
||||
return () => {
|
||||
clearInterval(positionInterval);
|
||||
if ('mediaSession' in navigator) {
|
||||
navigator.mediaSession.setActionHandler('play', null);
|
||||
navigator.mediaSession.setActionHandler('pause', null);
|
||||
navigator.mediaSession.setActionHandler('previoustrack', null);
|
||||
navigator.mediaSession.setActionHandler('nexttrack', null);
|
||||
navigator.mediaSession.setActionHandler('seekto', null);
|
||||
try {
|
||||
const mediaSession = navigator.mediaSession as MediaSession & {
|
||||
setActionHandler(action: 'togglefavorite', handler: MediaSessionActionHandler | null): void;
|
||||
};
|
||||
mediaSession.setActionHandler('togglefavorite', null);
|
||||
} catch (error) {
|
||||
// togglefavorite might not be supported
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('MediaSession setup failed:', error);
|
||||
}
|
||||
}, [currentTrack, isPlaying, isClient, playPreviousTrack, playNextTrack, onTrackPlay, onTrackPause, toggleCurrentTrackStar]);
|
||||
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
e.stopPropagation(); // Prevent triggering fullscreen
|
||||
if (audioCurrent && currentTrack) {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left;
|
||||
@@ -319,20 +578,135 @@ export const AudioPlayer: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlayPause = () => {
|
||||
const togglePlayPause = async () => {
|
||||
if (audioCurrent && currentTrack) {
|
||||
// Detect if running as PWA
|
||||
const isPWA = window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||||
|
||||
console.log('🎵 togglePlayPause called:', {
|
||||
isPlaying,
|
||||
isMobile,
|
||||
isPWA,
|
||||
audioInitialized,
|
||||
currentTrackUrl: currentTrack.url,
|
||||
audioSrc: audioCurrent.src,
|
||||
audioReadyState: audioCurrent.readyState
|
||||
});
|
||||
|
||||
if (isPlaying) {
|
||||
console.log('⏸️ Pausing audio');
|
||||
audioCurrent.pause();
|
||||
setIsPlaying(false);
|
||||
onTrackPause(audioCurrent.currentTime);
|
||||
} else {
|
||||
audioCurrent.play().then(() => {
|
||||
try {
|
||||
// PWA-specific initialization if needed
|
||||
if (isPWA && !audioInitialized) {
|
||||
console.log('🔧 PWA detected - initializing audio context...');
|
||||
try {
|
||||
const AudioContextClass = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (AudioContextClass) {
|
||||
const audioContext = new AudioContextClass();
|
||||
if (audioContext.state === 'suspended') {
|
||||
await audioContext.resume();
|
||||
}
|
||||
setAudioInitialized(true);
|
||||
console.log('✅ PWA audio context initialized');
|
||||
}
|
||||
} catch (contextError) {
|
||||
console.log('⚠️ PWA audio context initialization failed:', contextError);
|
||||
}
|
||||
}
|
||||
|
||||
// On mobile, ensure audio element is properly loaded before playing
|
||||
if (isMobile) {
|
||||
// Ensure the audio element has the correct source
|
||||
if (audioCurrent.src !== currentTrack.url) {
|
||||
console.log('🔄 Setting audio source:', currentTrack.url);
|
||||
audioCurrent.src = currentTrack.url;
|
||||
audioCurrent.load(); // Force reload the audio element
|
||||
}
|
||||
|
||||
// Wait for the audio to be ready to play
|
||||
if (audioCurrent.readyState < 3) { // HAVE_FUTURE_DATA
|
||||
console.log('⏳ Waiting for audio to be ready...');
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
audioCurrent.removeEventListener('canplay', handleCanPlay);
|
||||
audioCurrent.removeEventListener('error', handleError);
|
||||
reject(new Error('Audio load timeout'));
|
||||
}, 10000); // 10 second timeout
|
||||
|
||||
const handleCanPlay = () => {
|
||||
console.log('✅ Audio ready to play');
|
||||
clearTimeout(timeout);
|
||||
audioCurrent.removeEventListener('canplay', handleCanPlay);
|
||||
audioCurrent.removeEventListener('error', handleError);
|
||||
resolve(void 0);
|
||||
};
|
||||
const handleError = () => {
|
||||
console.log('❌ Audio load error');
|
||||
clearTimeout(timeout);
|
||||
audioCurrent.removeEventListener('canplay', handleCanPlay);
|
||||
audioCurrent.removeEventListener('error', handleError);
|
||||
reject(new Error('Audio failed to load'));
|
||||
};
|
||||
audioCurrent.addEventListener('canplay', handleCanPlay);
|
||||
audioCurrent.addEventListener('error', handleError);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('▶️ Attempting to play audio...');
|
||||
await audioCurrent.play();
|
||||
setIsPlaying(true);
|
||||
setAudioInitialized(true);
|
||||
onTrackPlay(currentTrack);
|
||||
}).catch((error) => {
|
||||
console.error('Failed to play audio:', error);
|
||||
setIsPlaying(false);
|
||||
});
|
||||
console.log('✅ Audio play successful');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to play audio:', error);
|
||||
|
||||
// Additional mobile-specific handling
|
||||
if (isMobile) {
|
||||
try {
|
||||
console.log('🔄 Attempting mobile audio recovery...');
|
||||
|
||||
// Try creating and resuming audio context
|
||||
const AudioContextClass = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (AudioContextClass) {
|
||||
const audioContext = new AudioContextClass();
|
||||
if (audioContext.state === 'suspended') {
|
||||
await audioContext.resume();
|
||||
}
|
||||
setAudioInitialized(true);
|
||||
}
|
||||
|
||||
// Force load and retry
|
||||
audioCurrent.load();
|
||||
await new Promise(resolve => setTimeout(resolve, 200)); // Small delay for iOS
|
||||
console.log('🔄 Retrying audio play...');
|
||||
await audioCurrent.play();
|
||||
setIsPlaying(true);
|
||||
onTrackPlay(currentTrack);
|
||||
console.log('✅ Audio play retry successful');
|
||||
} catch (retryError) {
|
||||
console.error('❌ Audio play retry failed:', retryError);
|
||||
setIsPlaying(false);
|
||||
|
||||
// Show user-friendly error on mobile
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Playback Error",
|
||||
description: isPWA
|
||||
? "Unable to play audio in PWA mode. Try refreshing the app or playing in Safari browser."
|
||||
: "Unable to play audio. Please try again or check your connection.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -354,109 +728,121 @@ export const AudioPlayer: React.FC = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Mini player (collapsed state)
|
||||
if (isMinimized) {
|
||||
// Mobile compact mini player :3
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 z-50">
|
||||
<div
|
||||
className="bg-background/95 backdrop-blur-xs border rounded-lg shadow-lg cursor-pointer hover:scale-[1.02] transition-transform w-80"
|
||||
onClick={() => setIsMinimized(false)}
|
||||
>
|
||||
<div className="flex items-center p-3">
|
||||
<Image
|
||||
src={currentTrack.coverArt || '/default-user.jpg'}
|
||||
alt={currentTrack.name}
|
||||
width={40}
|
||||
height={40}
|
||||
className="w-10 h-10 rounded-md shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0 mx-3">
|
||||
<div className="overflow-hidden">
|
||||
<p className="font-semibold text-sm whitespace-nowrap animate-infinite-scroll">
|
||||
{currentTrack.name}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">{currentTrack.artist}</p>
|
||||
</div>
|
||||
{/* Heart icon for favoriting */}
|
||||
<button
|
||||
className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors mr-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleCurrentTrackStar();
|
||||
}}
|
||||
title={currentTrack.starred ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
<Heart
|
||||
className={`w-4 h-4 ${currentTrack.starred ? 'text-primary fill-primary' : 'text-gray-400'}`}
|
||||
<>
|
||||
<div className="fixed bottom-16 left-0 right-0 z-[60] bg-background/95 backdrop-blur-sm border-t shadow-lg mobile-audio-player mobile-safe-bottom">
|
||||
<div className="px-4 py-3">
|
||||
{/* Progress bar at top for mobile */}
|
||||
<div className="mb-3">
|
||||
<Progress
|
||||
value={progress}
|
||||
className="h-1 cursor-pointer progress-mobile"
|
||||
onClick={handleProgressClick}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<button className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors" onClick={playPreviousTrack}>
|
||||
<FaBackward className="w-3 h-3" />
|
||||
</button>
|
||||
<button className="p-2 hover:bg-gray-700/50 rounded-full transition-colors" onClick={togglePlayPause}>
|
||||
{isPlaying ? <FaPause className="w-4 h-4" /> : <FaPlay className="w-4 h-4" />}
|
||||
</button>
|
||||
<button className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors" onClick={playNextTrack}>
|
||||
<FaForward className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Track info with swipe gestures */}
|
||||
<div
|
||||
className="flex items-center flex-1 min-w-0 cursor-pointer"
|
||||
onClick={() => setIsFullScreen(true)}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<Image
|
||||
src={currentTrack.coverArt || '/default-user.jpg'}
|
||||
alt={currentTrack.name}
|
||||
width={48}
|
||||
height={48}
|
||||
className="w-12 h-12 rounded-lg mr-3 shrink-0 shadow-sm"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-sm truncate">{currentTrack.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{currentTrack.artist}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile controls - Only heart and play/pause */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
className="p-3 hover:bg-muted/50 rounded-full transition-all duration-200 active:scale-95 touch-manipulation"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleCurrentTrackStar();
|
||||
}}
|
||||
type="button"
|
||||
aria-label={currentTrack.starred ? 'Remove from favorites' : 'Add to favorites'}
|
||||
title={currentTrack.starred ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
<Heart
|
||||
className={`w-4 h-4 ${currentTrack.starred ? 'text-primary fill-primary' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className="p-4 hover:bg-muted/50 rounded-full transition-all duration-200 active:scale-95 bg-primary/10 touch-manipulation"
|
||||
onClick={togglePlayPause}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
type="button"
|
||||
data-testid="play-pause-button"
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? <FaPause className="w-5 h-5" /> : <FaPlay className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<audio ref={audioRef} hidden />
|
||||
|
||||
{/* Full Screen Player for mobile - rendered outside mini player */}
|
||||
<FullScreenPlayer
|
||||
isOpen={isFullScreen}
|
||||
onClose={() => setIsFullScreen(false)}
|
||||
onOpenQueue={handleOpenQueue}
|
||||
/>
|
||||
|
||||
{/* Single audio element - shared across all UI states */}
|
||||
<audio
|
||||
ref={audioRef}
|
||||
playsInline
|
||||
preload="metadata"
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<audio ref={preloadAudioRef} hidden preload="metadata" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Compact floating player (default state)
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 right-4 z-50">
|
||||
<div className="bg-background/95 backdrop-blur-xs border rounded-lg shadow-lg p-3 cursor-pointer hover:scale-[1.01] transition-transform">
|
||||
<div className="flex items-center">
|
||||
{/* Track info */}
|
||||
<div className="flex items-center flex-1 min-w-0">
|
||||
<Image
|
||||
src={
|
||||
currentTrack.coverArt &&
|
||||
(currentTrack.coverArt.startsWith('http') || currentTrack.coverArt.startsWith('/'))
|
||||
? currentTrack.coverArt
|
||||
: '/default-user.jpg'
|
||||
}
|
||||
alt={currentTrack.name}
|
||||
width={48}
|
||||
height={48}
|
||||
className="w-12 h-12 rounded-md mr-4 shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold truncate text-base">{currentTrack.name}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">{currentTrack.artist}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center section with controls and progress */}
|
||||
<div className="flex flex-col items-center flex-1 justify-center">
|
||||
{/* Control buttons */}
|
||||
<div className="flex items-center justify-center space-x-3">
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
className={`p-2 hover:bg-gray-700/50 rounded-full transition-colors ${shuffle ? 'text-primary bg-primary/20' : ''}`}
|
||||
title={shuffle ? 'Shuffle On - Queue is shuffled' : 'Shuffle Off - Click to shuffle queue'}
|
||||
>
|
||||
<FaShuffle className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="p-2 hover:bg-gray-700/50 rounded-full transition-colors" onClick={playPreviousTrack}>
|
||||
<FaBackward className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="p-3 hover:bg-gray-700/50 rounded-full transition-colors" onClick={togglePlayPause}>
|
||||
{isPlaying ? <FaPause className="w-5 h-5" /> : <FaPlay className="w-5 h-5" />}
|
||||
</button>
|
||||
<button className="p-2 hover:bg-gray-700/50 rounded-full transition-colors" onClick={playNextTrack}>
|
||||
<FaForward className="w-4 h-4" />
|
||||
</button>
|
||||
// Desktop mini player (collapsed state)
|
||||
if (isMinimized) {
|
||||
return (
|
||||
<>
|
||||
<div className="fixed bottom-4 left-4 z-50">
|
||||
<div
|
||||
className="bg-background/95 backdrop-blur-xs border rounded-lg shadow-lg cursor-pointer hover:scale-[1.02] transition-transform w-80"
|
||||
onClick={() => setIsMinimized(false)}
|
||||
>
|
||||
<div className="flex items-center p-3">
|
||||
<Image
|
||||
src={currentTrack.coverArt || '/default-user.jpg'}
|
||||
alt={currentTrack.name}
|
||||
width={40}
|
||||
height={40}
|
||||
className="w-10 h-10 rounded-md shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0 mx-3">
|
||||
<div className="overflow-hidden">
|
||||
<p className="font-semibold text-sm whitespace-nowrap animate-infinite-scroll">
|
||||
{currentTrack.name}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">{currentTrack.artist}</p>
|
||||
</div>
|
||||
{/* Heart icon for favoriting */}
|
||||
<button
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors flex items-center justify-center"
|
||||
className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors mr-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleCurrentTrackStar();
|
||||
@@ -464,51 +850,144 @@ export const AudioPlayer: React.FC = () => {
|
||||
title={currentTrack.starred ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
<Heart
|
||||
className={`w-5 h-5 ${currentTrack.starred ? 'text-primary fill-primary' : ''}`}
|
||||
className={`w-4 h-4 ${currentTrack.starred ? 'text-primary fill-primary' : 'text-gray-400'}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{/* <div className="flex items-center space-x-2 w-80">
|
||||
<span className="text-xs text-muted-foreground w-8 text-right">
|
||||
{formatTime(audioCurrent?.currentTime ?? 0)}
|
||||
</span>
|
||||
<Progress value={progress} className="flex-1 cursor-pointer h-1" onClick={handleProgressClick}/>
|
||||
<span className="text-xs text-muted-foreground w-8">
|
||||
{formatTime(audioCurrent?.duration ?? 0)}
|
||||
</span>
|
||||
</div> */}
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<button className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors" onClick={playPreviousTrack}>
|
||||
<FaBackward className="w-3 h-3" />
|
||||
</button>
|
||||
<button className="p-2 hover:bg-gray-700/50 rounded-full transition-colors" onClick={togglePlayPause}>
|
||||
{isPlaying ? <FaPause className="w-4 h-4" /> : <FaPlay className="w-4 h-4" />}
|
||||
</button>
|
||||
<button className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors" onClick={playNextTrack}>
|
||||
<FaForward className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right side buttons */}
|
||||
<div className="flex items-center justify-end space-x-2 flex-1">
|
||||
<button
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors"
|
||||
onClick={() => setIsFullScreen(true)}
|
||||
title="Full Screen"
|
||||
>
|
||||
<FaExpand className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors"
|
||||
onClick={() => setIsMinimized(true)}
|
||||
title="Minimize"
|
||||
>
|
||||
<FaCompress className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Single audio element - shared across all UI states */}
|
||||
<audio
|
||||
ref={audioRef}
|
||||
playsInline
|
||||
preload="metadata"
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<audio ref={preloadAudioRef} hidden preload="metadata" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop compact floating player (default state)
|
||||
return (
|
||||
<>
|
||||
<div className="fixed bottom-4 left-4 right-4 z-50">
|
||||
<div className="bg-background/95 backdrop-blur-xs border rounded-lg shadow-lg p-3 cursor-pointer hover:scale-[1.01] transition-transform">
|
||||
<div className="flex items-center">
|
||||
{/* Track info */}
|
||||
<div className="flex items-center flex-1 min-w-0">
|
||||
<Image
|
||||
src={
|
||||
currentTrack.coverArt &&
|
||||
(currentTrack.coverArt.startsWith('http') || currentTrack.coverArt.startsWith('/'))
|
||||
? currentTrack.coverArt
|
||||
: '/default-user.jpg'
|
||||
}
|
||||
alt={currentTrack.name}
|
||||
width={48}
|
||||
height={48}
|
||||
className="w-12 h-12 rounded-md mr-4 shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold truncate text-base">{currentTrack.name}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">{currentTrack.artist}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center section with controls and progress */}
|
||||
<div className="flex flex-col items-center flex-1 justify-center">
|
||||
{/* Control buttons */}
|
||||
<div className="flex items-center justify-center space-x-3">
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
className={`p-2 hover:bg-gray-700/50 rounded-full transition-colors ${shuffle ? 'text-primary bg-primary/20' : ''}`}
|
||||
title={shuffle ? 'Shuffle On - Queue is shuffled' : 'Shuffle Off - Click to shuffle queue'}
|
||||
>
|
||||
<FaShuffle className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="p-2 hover:bg-gray-700/50 rounded-full transition-colors" onClick={playPreviousTrack}>
|
||||
<FaBackward className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="p-3 hover:bg-gray-700/50 rounded-full transition-colors" onClick={togglePlayPause}>
|
||||
{isPlaying ? <FaPause className="w-5 h-5" /> : <FaPlay className="w-5 h-5" />}
|
||||
</button>
|
||||
<button className="p-2 hover:bg-gray-700/50 rounded-full transition-colors" onClick={playNextTrack}>
|
||||
<FaForward className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleCurrentTrackStar();
|
||||
}}
|
||||
title={currentTrack.starred ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
<Heart
|
||||
className={`w-5 h-5 ${currentTrack.starred ? 'text-primary fill-primary' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{/* <div className="flex items-center space-x-2 w-80">
|
||||
<span className="text-xs text-muted-foreground w-8 text-right">
|
||||
{formatTime(audioCurrent?.currentTime ?? 0)}
|
||||
</span>
|
||||
<Progress value={progress} className="flex-1 cursor-pointer h-1" onClick={handleProgressClick}/>
|
||||
<span className="text-xs text-muted-foreground w-8">
|
||||
{formatTime(audioCurrent?.duration ?? 0)}
|
||||
</span>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
{/* Right side buttons */}
|
||||
<div className="flex items-center justify-end space-x-2 flex-1">
|
||||
<button
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors"
|
||||
onClick={() => setIsFullScreen(true)}
|
||||
title="Full Screen"
|
||||
>
|
||||
<FaExpand className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors"
|
||||
onClick={() => setIsMinimized(true)}
|
||||
title="Minimize"
|
||||
>
|
||||
<FaCompress className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full Screen Player */}
|
||||
<FullScreenPlayer
|
||||
isOpen={isFullScreen}
|
||||
onClose={() => setIsFullScreen(false)}
|
||||
onOpenQueue={handleOpenQueue}
|
||||
/>
|
||||
</div>
|
||||
<audio ref={audioRef} hidden />
|
||||
<audio ref={preloadAudioRef} hidden preload="metadata" />
|
||||
|
||||
{/* Full Screen Player */}
|
||||
<FullScreenPlayer
|
||||
isOpen={isFullScreen}
|
||||
onClose={() => setIsFullScreen(false)}
|
||||
onOpenQueue={handleOpenQueue}
|
||||
{/* Single audio element - shared across all UI states with mobile support */}
|
||||
<audio
|
||||
ref={audioRef}
|
||||
playsInline
|
||||
preload="metadata"
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<audio ref={preloadAudioRef} hidden preload="metadata" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -53,7 +53,15 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [shuffle, setShuffle] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const api = useMemo(() => getNavidromeAPI(), []);
|
||||
const api = useMemo(() => {
|
||||
const navidromeApi = getNavidromeAPI();
|
||||
if (!navidromeApi) {
|
||||
console.warn('⚠️ Navidrome API not configured');
|
||||
} else {
|
||||
console.log('✅ Navidrome API initialized');
|
||||
}
|
||||
return navidromeApi;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const savedQueue = localStorage.getItem('navidrome-audioQueue');
|
||||
@@ -98,14 +106,18 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
if (!api) {
|
||||
throw new Error('Navidrome API not configured');
|
||||
}
|
||||
|
||||
const streamUrl = api.getStreamUrl(song.id);
|
||||
console.log('🎵 Creating track with stream URL:', streamUrl);
|
||||
|
||||
return {
|
||||
id: song.id,
|
||||
name: song.title,
|
||||
url: api.getStreamUrl(song.id),
|
||||
url: streamUrl,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 512) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId,
|
||||
starred: !!song.starred
|
||||
|
||||
67
app/components/BottomNavigation.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { Home, Search, Disc, Users, Music, Heart, List, Settings } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const navigationItems: NavItem[] = [
|
||||
{ href: '/', label: 'Home', icon: Home },
|
||||
{ href: '/search', label: 'Search', icon: Search },
|
||||
{ href: '/library', label: 'Library', icon: Music },
|
||||
{ href: '/queue', label: 'Queue', icon: List },
|
||||
];
|
||||
|
||||
export function BottomNavigation() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const handleNavigation = (href: string) => {
|
||||
router.push(href);
|
||||
};
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === '/') {
|
||||
return pathname === '/';
|
||||
}
|
||||
return pathname.startsWith(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-[50] bg-background/95 backdrop-blur-sm border-t border-border">
|
||||
<div className="flex items-center justify-around px-2 py-2 pb-safe mb-2">
|
||||
{navigationItems.map((item) => {
|
||||
const isItemActive = isActive(item.href);
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.href}
|
||||
onClick={() => handleNavigation(item.href)}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center p-2 rounded-lg transition-all duration-200 min-w-[60px] touch-manipulation",
|
||||
"active:scale-95 active:bg-primary/20",
|
||||
isItemActive
|
||||
? "text-primary bg-primary/10"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("w-5 h-5 mb-1", isItemActive && "text-primary")} />
|
||||
<span className={cn(
|
||||
"text-xs font-medium",
|
||||
isItemActive ? "text-primary" : "text-muted-foreground"
|
||||
)}>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -143,7 +143,7 @@ export function CacheManagement() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="break-inside-avoid">
|
||||
<Card className="break-inside-avoid py-5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { lrcLibClient } from '@/lib/lrclib';
|
||||
import Link from 'next/link';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import {
|
||||
FaPlay,
|
||||
FaPause,
|
||||
@@ -34,8 +35,20 @@ interface FullScreenPlayerProps {
|
||||
onOpenQueue?: () => void;
|
||||
}
|
||||
|
||||
type MobileTab = 'player' | 'lyrics' | 'queue';
|
||||
|
||||
export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onClose, onOpenQueue }) => {
|
||||
const { currentTrack, playPreviousTrack, playNextTrack, shuffle, toggleShuffle, toggleCurrentTrackStar } = useAudioPlayer();
|
||||
const {
|
||||
currentTrack,
|
||||
playPreviousTrack,
|
||||
playNextTrack,
|
||||
shuffle,
|
||||
toggleShuffle,
|
||||
toggleCurrentTrackStar,
|
||||
queue
|
||||
} = useAudioPlayer();
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const router = useRouter();
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
@@ -47,8 +60,19 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
||||
const [lyrics, setLyrics] = useState<LyricLine[]>([]);
|
||||
const [currentLyricIndex, setCurrentLyricIndex] = useState(-1);
|
||||
const [showLyrics, setShowLyrics] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<MobileTab>('player');
|
||||
const lyricsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Debug logging for component changes
|
||||
useEffect(() => {
|
||||
console.log('🔍 FullScreenPlayer state changed:', {
|
||||
isOpen,
|
||||
currentTrack,
|
||||
currentTrackKeys: currentTrack ? Object.keys(currentTrack) : 'null',
|
||||
queueLength: queue?.length || 0
|
||||
});
|
||||
}, [isOpen, currentTrack, queue?.length]);
|
||||
|
||||
// Load lyrics when track changes
|
||||
useEffect(() => {
|
||||
const loadLyrics = async () => {
|
||||
@@ -72,7 +96,7 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
||||
setLyrics([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load lyrics:', error);
|
||||
console.log('Failed to load lyrics:', error);
|
||||
setLyrics([]);
|
||||
}
|
||||
};
|
||||
@@ -88,62 +112,106 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
||||
}
|
||||
}, [lyrics, currentTime, currentLyricIndex]);
|
||||
|
||||
// Auto-scroll lyrics using lyricsRef
|
||||
// Auto-scroll lyrics using lyricsRef - Disabled on mobile to prevent iOS audio issues
|
||||
useEffect(() => {
|
||||
if (currentLyricIndex >= 0 && lyrics.length > 0 && showLyrics && lyricsRef.current) {
|
||||
// Only auto-scroll on desktop to avoid iOS audio interference
|
||||
const shouldScroll = !isMobile && showLyrics && lyrics.length > 0;
|
||||
|
||||
if (currentLyricIndex >= 0 && shouldScroll && lyricsRef.current) {
|
||||
const scrollTimeout = setTimeout(() => {
|
||||
// Find the ScrollArea viewport
|
||||
const scrollViewport = lyricsRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement;
|
||||
const currentLyricElement = lyricsRef.current?.querySelector(`[data-lyric-index="${currentLyricIndex}"]`) as HTMLElement;
|
||||
|
||||
if (scrollViewport && currentLyricElement) {
|
||||
const containerHeight = scrollViewport.clientHeight;
|
||||
const elementTop = currentLyricElement.offsetTop;
|
||||
const elementHeight = currentLyricElement.offsetHeight;
|
||||
try {
|
||||
const scrollContainer = lyricsRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement;
|
||||
const currentLyricElement = lyricsRef.current?.querySelector(`[data-lyric-index="${currentLyricIndex}"]`) as HTMLElement;
|
||||
|
||||
// Calculate scroll position to center the current lyric
|
||||
const targetScrollTop = elementTop - (containerHeight / 2) + (elementHeight / 2);
|
||||
|
||||
scrollViewport.scrollTo({
|
||||
top: Math.max(0, targetScrollTop),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
if (scrollContainer && currentLyricElement) {
|
||||
const containerHeight = scrollContainer.clientHeight;
|
||||
const elementTop = currentLyricElement.offsetTop;
|
||||
const elementHeight = currentLyricElement.offsetHeight;
|
||||
const targetScrollTop = elementTop - (containerHeight / 2) + (elementHeight / 2);
|
||||
|
||||
scrollContainer.scrollTo({
|
||||
top: Math.max(0, targetScrollTop),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Lyrics scroll failed:', error);
|
||||
}
|
||||
}, 100);
|
||||
}, 200);
|
||||
|
||||
return () => clearTimeout(scrollTimeout);
|
||||
}
|
||||
}, [currentLyricIndex, showLyrics, lyrics.length]);
|
||||
}, [currentLyricIndex, showLyrics, lyrics.length, isMobile]);
|
||||
|
||||
// Reset lyrics to top when song changes
|
||||
// Reset lyrics to top when song changes - Disabled on mobile to prevent iOS audio issues
|
||||
useEffect(() => {
|
||||
if (currentTrack && showLyrics && lyricsRef.current) {
|
||||
// Reset scroll position using lyricsRef
|
||||
const resetScroll = () => {
|
||||
const scrollViewport = lyricsRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement;
|
||||
|
||||
if (scrollViewport) {
|
||||
scrollViewport.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'instant' // Use instant for track changes
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Small delay to ensure DOM is ready
|
||||
// Only reset scroll on desktop to avoid iOS audio interference
|
||||
const shouldReset = !isMobile && showLyrics && lyrics.length > 0;
|
||||
|
||||
if (currentTrack?.id && shouldReset && lyricsRef.current) {
|
||||
const resetTimeout = setTimeout(() => {
|
||||
resetScroll();
|
||||
try {
|
||||
const scrollContainer = lyricsRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement;
|
||||
|
||||
if (scrollContainer) {
|
||||
scrollContainer.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'instant'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Lyrics reset scroll failed:', error);
|
||||
}
|
||||
setCurrentLyricIndex(-1);
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(resetTimeout);
|
||||
}
|
||||
}, [currentTrack?.id, showLyrics, currentTrack]); // Only reset when track ID changes
|
||||
}, [currentTrack?.id, showLyrics, isMobile, lyrics.length]);
|
||||
|
||||
// Sync with main audio player (improved responsiveness)
|
||||
useEffect(() => {
|
||||
const syncWithMainPlayer = () => {
|
||||
const mainAudio = document.querySelector('audio') as HTMLAudioElement;
|
||||
|
||||
console.log('=== FULLSCREEN PLAYER AUDIO DEBUG ===');
|
||||
console.log('currentTrack from context:', currentTrack);
|
||||
console.log('currentTrack keys:', currentTrack ? Object.keys(currentTrack) : 'null');
|
||||
if (currentTrack) {
|
||||
console.log('currentTrack.url:', currentTrack.url);
|
||||
console.log('currentTrack.id:', currentTrack.id);
|
||||
console.log('currentTrack.name:', currentTrack.name);
|
||||
console.log('currentTrack.artist:', currentTrack.artist);
|
||||
}
|
||||
console.log('Audio element found:', !!mainAudio);
|
||||
|
||||
if (mainAudio) {
|
||||
console.log('Audio element src:', mainAudio.src);
|
||||
console.log('Audio element currentSrc:', mainAudio.currentSrc);
|
||||
console.log('Audio state:', {
|
||||
currentTime: mainAudio.currentTime,
|
||||
duration: mainAudio.duration,
|
||||
paused: mainAudio.paused,
|
||||
ended: mainAudio.ended,
|
||||
readyState: mainAudio.readyState,
|
||||
networkState: mainAudio.networkState,
|
||||
error: mainAudio.error
|
||||
});
|
||||
|
||||
// Check if audio source matches current track
|
||||
if (currentTrack) {
|
||||
const audioSourceMatches = mainAudio.src === currentTrack.url || mainAudio.currentSrc === currentTrack.url;
|
||||
console.log('Audio source matches current track URL:', audioSourceMatches);
|
||||
if (!audioSourceMatches) {
|
||||
console.log('⚠️ Audio source mismatch!');
|
||||
console.log('Expected:', currentTrack.url);
|
||||
console.log('Audio src:', mainAudio.src);
|
||||
console.log('Audio currentSrc:', mainAudio.currentSrc);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('==========================================');
|
||||
|
||||
if (mainAudio && currentTrack) {
|
||||
const newCurrentTime = mainAudio.currentTime;
|
||||
const newDuration = mainAudio.duration || 0;
|
||||
@@ -206,20 +274,96 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
||||
setDominantColor(`rgb(${r}, ${g}, ${b})`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to extract color:', error);
|
||||
console.log('Failed to extract color:', error);
|
||||
}
|
||||
};
|
||||
img.src = currentTrack.coverArt;
|
||||
}, [currentTrack]);
|
||||
|
||||
const togglePlayPause = () => {
|
||||
const mainAudio = document.querySelector('audio') as HTMLAudioElement;
|
||||
if (!mainAudio) return;
|
||||
|
||||
if (isPlaying) {
|
||||
mainAudio.pause();
|
||||
console.log('🎵 FullScreenPlayer Toggle Play/Pause clicked');
|
||||
|
||||
// Find the main audio player's play/pause button and click it
|
||||
// This ensures we use the same logic as the main player
|
||||
const mainPlayButton = document.querySelector('[data-testid="play-pause-button"]') as HTMLButtonElement;
|
||||
|
||||
if (mainPlayButton) {
|
||||
console.log('✅ Found main play button, triggering click');
|
||||
mainPlayButton.click();
|
||||
} else {
|
||||
mainAudio.play();
|
||||
console.log('❌ Main play button not found, falling back to direct audio control');
|
||||
|
||||
// Fallback to direct audio control if button not found
|
||||
const mainAudio = document.querySelector('audio') as HTMLAudioElement;
|
||||
if (!mainAudio) {
|
||||
console.log('❌ No audio element found');
|
||||
|
||||
// Try to find ALL audio elements for debugging
|
||||
const allAudio = document.querySelectorAll('audio');
|
||||
console.log('🔍 Found audio elements:', allAudio.length);
|
||||
allAudio.forEach((audio, index) => {
|
||||
console.log(`Audio ${index}:`, {
|
||||
src: audio.src,
|
||||
currentSrc: audio.currentSrc,
|
||||
paused: audio.paused,
|
||||
hidden: audio.hidden,
|
||||
style: audio.style.display
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔍 Detailed audio element state:');
|
||||
console.log('- Audio src:', mainAudio.src);
|
||||
console.log('- Audio currentSrc:', mainAudio.currentSrc);
|
||||
console.log('- Audio paused:', mainAudio.paused);
|
||||
console.log('- Audio currentTime:', mainAudio.currentTime);
|
||||
console.log('- Audio duration:', mainAudio.duration);
|
||||
console.log('- Audio readyState:', mainAudio.readyState, '(0=HAVE_NOTHING, 1=HAVE_METADATA, 2=HAVE_CURRENT_DATA, 3=HAVE_FUTURE_DATA, 4=HAVE_ENOUGH_DATA)');
|
||||
console.log('- Audio networkState:', mainAudio.networkState, '(0=EMPTY, 1=IDLE, 2=LOADING, 3=NO_SOURCE)');
|
||||
console.log('- Audio error:', mainAudio.error);
|
||||
console.log('- Audio ended:', mainAudio.ended);
|
||||
console.log('- Audio seeking:', mainAudio.seeking);
|
||||
console.log('- Audio volume:', mainAudio.volume);
|
||||
console.log('- Audio muted:', mainAudio.muted);
|
||||
console.log('- Audio autoplay:', mainAudio.autoplay);
|
||||
console.log('- Audio loop:', mainAudio.loop);
|
||||
console.log('- Audio preload:', mainAudio.preload);
|
||||
console.log('- Audio crossOrigin:', mainAudio.crossOrigin);
|
||||
|
||||
if (isPlaying) {
|
||||
console.log('⏸️ Attempting to pause audio');
|
||||
try {
|
||||
mainAudio.pause();
|
||||
console.log('✅ Audio pause() succeeded');
|
||||
} catch (error) {
|
||||
console.log('❌ Audio pause() failed:', error);
|
||||
}
|
||||
} else {
|
||||
console.log('▶️ Attempting to play audio');
|
||||
|
||||
// Check if audio has a valid source
|
||||
if (!mainAudio.src && !mainAudio.currentSrc) {
|
||||
console.log('❌ Audio has no source set!');
|
||||
console.log('currentTrack:', currentTrack);
|
||||
if (currentTrack) {
|
||||
console.log('Setting audio source to:', currentTrack.url);
|
||||
mainAudio.src = currentTrack.url;
|
||||
mainAudio.load();
|
||||
}
|
||||
}
|
||||
|
||||
mainAudio.play().then(() => {
|
||||
console.log('✅ Audio play() succeeded');
|
||||
}).catch((error) => {
|
||||
console.log('❌ Audio play() failed:', error);
|
||||
console.log('Error details:', {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
code: error.code
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -269,212 +413,485 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
||||
if (!isOpen || !currentTrack) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black overflow-hidden">
|
||||
{/* Blurred background image */}
|
||||
<div className="fixed inset-0 z-[70] bg-black overflow-hidden">
|
||||
{/* Enhanced Blurred background image */}
|
||||
{currentTrack.coverArt && (
|
||||
<div
|
||||
className="absolute inset-0 w-full h-full"
|
||||
style={{
|
||||
backgroundImage: `url(${currentTrack.coverArt})`,
|
||||
backgroundSize: '120%',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
filter: 'blur(20px) brightness(0.3)',
|
||||
transform: 'scale(1.1)',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 w-full h-full">
|
||||
{/* Main background */}
|
||||
<div
|
||||
className="absolute inset-0 w-full h-full"
|
||||
style={{
|
||||
backgroundImage: `url(${currentTrack.coverArt})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
filter: 'blur(20px) brightness(0.3)',
|
||||
transform: 'scale(1.1)',
|
||||
}}
|
||||
/>
|
||||
{/* Top gradient blur for mobile */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-32"
|
||||
style={{
|
||||
background: `linear-gradient(to bottom,
|
||||
rgba(0,0,0,0.8) 0%,
|
||||
rgba(0,0,0,0.4) 50%,
|
||||
transparent 100%)`,
|
||||
backdropFilter: 'blur(10px)',
|
||||
}}
|
||||
/>
|
||||
{/* Bottom gradient blur for mobile */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-32"
|
||||
style={{
|
||||
background: `linear-gradient(to top,
|
||||
rgba(0,0,0,0.8) 0%,
|
||||
rgba(0,0,0,0.4) 50%,
|
||||
transparent 100%)`,
|
||||
backdropFilter: 'blur(10px)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay for better contrast */}
|
||||
<div className="absolute inset-0 bg-black/50" />
|
||||
<div className="relative h-full w-full">
|
||||
{/* Floating Header */}
|
||||
<div className="absolute top-0 right-0 z-50 p-4 lg:p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
{onOpenQueue && (
|
||||
<button
|
||||
onClick={onOpenQueue}
|
||||
className="text-white hover:bg-white/20 p-2 rounded-full transition-colors flex items-center justify-center w-10 h-10"
|
||||
title="Open Queue"
|
||||
>
|
||||
<FaListUl className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
<div className="absolute inset-0 bg-black/30" />
|
||||
|
||||
<div className="relative h-full w-full flex flex-col">
|
||||
|
||||
{/* Mobile Close Handle */}
|
||||
{isMobile && (
|
||||
<div className="flex justify-center py-4 px-4">
|
||||
<div
|
||||
onClick={onClose}
|
||||
className="text-white hover:bg-white/20 p-2 rounded-full transition-colors flex items-center justify-center w-10 h-10"
|
||||
title="Close Player"
|
||||
className="cursor-pointer px-8 py-3 -mx-8 -my-3"
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<FaXmark className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="w-8 h-1 bg-gray-300 rounded-full opacity-60" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="h-full flex flex-col lg:flex-row gap-4 lg:gap-8 p-4 lg:p-6 overflow-hidden">
|
||||
{/* Left Side - Album Art and Controls */}
|
||||
<div className="flex flex-col items-center justify-center min-h-0 flex-1 min-w-0">
|
||||
{/* Album Art */}
|
||||
<div className="relative mb-4 lg:mb-6 shrink-0">
|
||||
<Image
|
||||
src={currentTrack.coverArt || '/default-album.png'}
|
||||
alt={currentTrack.album}
|
||||
width={320}
|
||||
height={320}
|
||||
className="w-56 h-56 sm:w-64 sm:h-64 lg:w-80 lg:h-80 rounded-lg shadow-2xl object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Track Info */}
|
||||
<div className="text-center mb-4 lg:mb-6 px-4 shrink-0 max-w-full">
|
||||
<h1 className="text-lg sm:text-xl lg:text-3xl font-bold text-foreground mb-2 line-clamp-2 leading-tight">
|
||||
{currentTrack.name}
|
||||
</h1>
|
||||
<Link href={`/artist/${currentTrack.artistId}`} className="text-base sm:text-lg lg:text-xl text-foreground/80 mb-1 line-clamp-1">
|
||||
{currentTrack.artist}
|
||||
</Link>
|
||||
<Link href={`/album/${currentTrack.albumId}`} className="text-sm sm:text-base lg:text-lg text-foreground/60 line-clamp-1 cursor-pointer hover:underline">
|
||||
{currentTrack.album}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="w-full max-w-sm lg:max-w-md mb-4 lg:mb-6 px-4 shrink-0">
|
||||
<div className="w-full" onClick={handleSeek}>
|
||||
<Progress value={progress} className="h-2 cursor-pointer" />
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-foreground/60 mt-2">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-3 sm:gap-4 lg:gap-6 mb-4 lg:mb-6 shrink-0">
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
className={`p-2 hover:bg-gray-700/50 rounded-full transition-colors ${
|
||||
shuffle ? 'text-primary bg-primary/20' : 'text-gray-400'
|
||||
}`}
|
||||
title={shuffle ? 'Shuffle On - Queue is shuffled' : 'Shuffle Off - Click to shuffle queue'}
|
||||
>
|
||||
<FaShuffle className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={playPreviousTrack}
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
|
||||
<FaBackward className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={togglePlayPause}
|
||||
className="p-3 hover:bg-gray-700/50 rounded-full transition-colors">
|
||||
{isPlaying ? (
|
||||
<FaPause className="w-8 h-8 sm:w-10 sm:h-10" />
|
||||
) : (
|
||||
<FaPlay className="w-8 h-8 sm:w-10 sm:h-10" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={playNextTrack}
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
|
||||
<FaForward className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={toggleCurrentTrackStar}
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors"
|
||||
title={currentTrack?.starred ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
<Heart
|
||||
className={`w-4 h-4 sm:w-5 sm:h-5 ${currentTrack?.starred ? 'text-primary fill-primary' : 'text-gray-400'}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{/* Volume and Lyrics Toggle */}
|
||||
<div className="flex items-center gap-3 shrink-0 justify-center">
|
||||
<button
|
||||
onMouseEnter={() => setShowVolumeSlider(true)}
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
|
||||
{volume === 0 ? (
|
||||
<FaVolumeXmark className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
) : (
|
||||
<FaVolumeHigh className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{lyrics.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowLyrics(!showLyrics)}
|
||||
className={`p-2 hover:bg-gray-700/50 rounded-full transition-colors ${
|
||||
showLyrics ? 'text-primary bg-primary/20' : 'text-gray-500'
|
||||
}`}
|
||||
title={showLyrics ? 'Hide Lyrics' : 'Show Lyrics'}
|
||||
{/* Desktop Header */}
|
||||
{!isMobile && (
|
||||
<div className="absolute top-0 right-0 z-10 p-4 lg:p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
{onOpenQueue && (
|
||||
<button
|
||||
onClick={onOpenQueue}
|
||||
className="text-white hover:bg-white/20 p-2 rounded-full transition-colors flex items-center justify-center w-10 h-10"
|
||||
title="Open Queue"
|
||||
>
|
||||
<FaQuoteLeft className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
<FaListUl className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showVolumeSlider && (
|
||||
<div
|
||||
className="w-16 sm:w-20 lg:w-24"
|
||||
onMouseLeave={() => setShowVolumeSlider(false)}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={volume * 100}
|
||||
onChange={handleVolumeChange}
|
||||
className="w-full accent-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white hover:bg-white/20 p-2 rounded-full transition-colors flex items-center justify-center w-10 h-10"
|
||||
title="Close Player"
|
||||
>
|
||||
<FaXmark className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right Side - Lyrics */}
|
||||
{showLyrics && lyrics.length > 0 && (
|
||||
<div className="flex-1 min-w-0 min-h-0 flex flex-col" ref={lyricsRef}>
|
||||
<div className="h-full flex flex-col">
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="space-y-2 sm:space-y-3 pl-4 pr-4 py-4">
|
||||
{lyrics.map((line, index) => (
|
||||
<div
|
||||
key={index}
|
||||
data-lyric-index={index}
|
||||
onClick={() => handleLyricClick(line.time)}
|
||||
className={`text-sm sm:text-base lg:text-base leading-relaxed transition-all duration-300 break-words cursor-pointer hover:text-foreground ${
|
||||
index === currentLyricIndex
|
||||
? 'text-foreground font-bold text-2xl'
|
||||
: index < currentLyricIndex
|
||||
? 'text-foreground/60'
|
||||
: 'text-foreground/40'
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{isMobile ? (
|
||||
/* Mobile Tab Content */
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{activeTab === 'player' && (
|
||||
<div className="h-full flex flex-col justify-center items-center px-8 py-4">
|
||||
{/* Mobile Album Art */}
|
||||
<div className="relative mb-6 shrink-0">
|
||||
<Image
|
||||
src={currentTrack.coverArt || '/default-album.png'}
|
||||
alt={currentTrack.album}
|
||||
width={260}
|
||||
height={260}
|
||||
className={`rounded-lg shadow-2xl object-cover transition-all duration-300 ${
|
||||
!isPlaying ? 'w-52 h-52 opacity-70 scale-95' : 'w-64 h-64'
|
||||
}`}
|
||||
style={{
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
hyphens: 'auto',
|
||||
paddingBottom: '4px',
|
||||
paddingLeft: '8px'
|
||||
}}
|
||||
title={`Click to jump to ${formatTime(line.time)}`}
|
||||
>
|
||||
{line.text || '♪'}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Track Info - Left Aligned and Heart on Same Line */}
|
||||
<div className="w-full mb-6 shrink-0">
|
||||
<div className="flex items-center justify-between mb-0">
|
||||
<h1 className="text-2xl font-bold text-foreground line-clamp-1 flex-1 text-left">
|
||||
{currentTrack.name}
|
||||
</h1>
|
||||
<button
|
||||
onClick={toggleCurrentTrackStar}
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors ml-3 pb-0"
|
||||
title={currentTrack?.starred ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
<Heart
|
||||
className={`w-6 h-6 ${currentTrack?.starred ? 'text-primary fill-primary' : 'text-gray-400'}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{/* Add extra padding at the bottom to allow last lyric to center */}
|
||||
<div style={{ height: '200px' }} />
|
||||
<Link
|
||||
href={`/artist/${currentTrack.artistId}`}
|
||||
className="text-lg text-foreground/80 line-clamp-1 block text-left mb-1"
|
||||
>
|
||||
{currentTrack.artist}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/album/${currentTrack.albumId}`}
|
||||
className="text-base text-foreground/60 line-clamp-1 cursor-pointer hover:underline block text-left"
|
||||
>
|
||||
{currentTrack.album}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="w-full mb-4 shrink-0">
|
||||
<div className="w-full" onClick={handleSeek}>
|
||||
<Progress value={progress} className="h-2 cursor-pointer" />
|
||||
</div>
|
||||
{/* Time below progress on mobile */}
|
||||
<div className="flex justify-between text-sm text-foreground/60 mt-2">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-6 mb-4 shrink-0">
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
className={`p-2 hover:bg-gray-700/50 rounded-full transition-colors ${
|
||||
shuffle ? 'text-primary bg-primary/20' : 'text-gray-400'
|
||||
}`}
|
||||
title={shuffle ? 'Shuffle On - Queue is shuffled' : 'Shuffle Off - Click to shuffle queue'}
|
||||
>
|
||||
<FaShuffle className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={playPreviousTrack}
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
|
||||
<FaBackward className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={togglePlayPause}
|
||||
className="p-4 hover:bg-gray-700/50 rounded-full transition-colors">
|
||||
{isPlaying ? (
|
||||
<FaPause className="w-10 h-10" />
|
||||
) : (
|
||||
<FaPlay className="w-10 h-10" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={playNextTrack}
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
|
||||
<FaForward className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onMouseEnter={() => setShowVolumeSlider(true)}
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
|
||||
{volume === 0 ? (
|
||||
<FaVolumeXmark className="w-5 h-5" />
|
||||
) : (
|
||||
<FaVolumeHigh className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Volume Slider */}
|
||||
{showVolumeSlider && (
|
||||
<div
|
||||
className="w-32 mb-4"
|
||||
onMouseLeave={() => setShowVolumeSlider(false)}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={volume * 100}
|
||||
onChange={handleVolumeChange}
|
||||
className="w-full accent-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{activeTab === 'lyrics' && lyrics.length > 0 && (
|
||||
<div className="h-full flex flex-col px-4">
|
||||
<div
|
||||
className="flex-1 overflow-y-auto"
|
||||
ref={lyricsRef}
|
||||
>
|
||||
<div className="space-y-3 py-4">
|
||||
{lyrics.map((line, index) => (
|
||||
<div
|
||||
key={index}
|
||||
data-lyric-index={index}
|
||||
onClick={() => handleLyricClick(line.time)}
|
||||
className={`text-base leading-relaxed transition-all duration-300 break-words cursor-pointer hover:text-foreground px-2 ${
|
||||
index === currentLyricIndex
|
||||
? 'text-foreground font-bold text-xl'
|
||||
: index < currentLyricIndex
|
||||
? 'text-foreground/60'
|
||||
: 'text-foreground/40'
|
||||
}`}
|
||||
style={{
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
hyphens: 'auto',
|
||||
paddingBottom: '4px'
|
||||
}}
|
||||
title={`Click to jump to ${formatTime(line.time)}`}
|
||||
>
|
||||
{line.text || '♪'}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ height: '200px' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'queue' && (
|
||||
<div className="h-full flex flex-col px-4">
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-2 py-4">
|
||||
{queue.map((track, index) => (
|
||||
<div
|
||||
key={`${track.id}-${index}`}
|
||||
className={`flex items-center p-3 rounded-lg ${
|
||||
track.id === currentTrack?.id ? 'bg-primary/20' : 'bg-gray-800/30'
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={track.coverArt || '/default-album.png'}
|
||||
alt={track.album}
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded mr-3"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">
|
||||
{track.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 truncate">
|
||||
{track.artist}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile Tab Bar */}
|
||||
<div className="flex-shrink-0 pb-safe">
|
||||
<div className="flex justify-around py-4 mb-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('player')}
|
||||
className={`flex items-center justify-center p-4 rounded-full transition-colors ${
|
||||
activeTab === 'player' ? 'text-primary bg-primary/20' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
<FaPlay className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
{lyrics.length > 0 && (
|
||||
<button
|
||||
onClick={() => setActiveTab('lyrics')}
|
||||
className={`flex items-center justify-center p-4 rounded-full transition-colors ${
|
||||
activeTab === 'lyrics' ? 'text-primary bg-primary/20' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
<FaQuoteLeft className="w-6 h-6" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('queue')}
|
||||
className={`flex items-center justify-center p-4 rounded-full transition-colors ${
|
||||
activeTab === 'queue' ? 'text-primary bg-primary/20' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
<FaListUl className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Desktop Layout */
|
||||
<div className="h-full flex flex-row gap-8 p-6 overflow-hidden">
|
||||
{/* Left Side - Album Art and Controls */}
|
||||
<div className="flex flex-col items-center justify-center min-h-0 flex-1 min-w-0">
|
||||
{/* Album Art */}
|
||||
<div className="relative mb-6 shrink-0">
|
||||
<Image
|
||||
src={currentTrack.coverArt || '/default-album.png'}
|
||||
alt={currentTrack.album}
|
||||
width={320}
|
||||
height={320}
|
||||
className="w-80 h-80 rounded-lg shadow-2xl object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Track Info */}
|
||||
<div className="text-center mb-6 px-4 shrink-0 max-w-full">
|
||||
<h1 className="text-3xl font-bold text-foreground line-clamp-2 leading-tight mb-2">
|
||||
{currentTrack.name}
|
||||
</h1>
|
||||
<Link href={`/artist/${currentTrack.artistId}`} className="text-xl text-foreground/80 mb-1 line-clamp-1">
|
||||
{currentTrack.artist}
|
||||
</Link>
|
||||
<Link href={`/album/${currentTrack.albumId}`} className="text-lg text-foreground/60 line-clamp-1 cursor-pointer hover:underline">
|
||||
{currentTrack.album}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="w-full max-w-md mb-6 px-4 shrink-0">
|
||||
<div className="w-full" onClick={handleSeek}>
|
||||
<Progress value={progress} className="h-2 cursor-pointer" />
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-foreground/60 mt-2">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-6 mb-6 shrink-0">
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
className={`p-2 hover:bg-gray-700/50 rounded-full transition-colors ${
|
||||
shuffle ? 'text-primary bg-primary/20' : 'text-gray-400'
|
||||
}`}
|
||||
title={shuffle ? 'Shuffle On - Queue is shuffled' : 'Shuffle Off - Click to shuffle queue'}
|
||||
>
|
||||
<FaShuffle className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={playPreviousTrack}
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
|
||||
<FaBackward className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={togglePlayPause}
|
||||
className="p-3 hover:bg-gray-700/50 rounded-full transition-colors">
|
||||
{isPlaying ? (
|
||||
<FaPause className="w-10 h-10" />
|
||||
) : (
|
||||
<FaPlay className="w-10 h-10" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={playNextTrack}
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
|
||||
<FaForward className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={toggleCurrentTrackStar}
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors"
|
||||
title={currentTrack?.starred ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
<Heart
|
||||
className={`w-5 h-5 ${currentTrack?.starred ? 'text-primary fill-primary' : 'text-gray-400'}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Volume and Lyrics Toggle - Desktop Only */}
|
||||
<div className="flex items-center gap-3 shrink-0 justify-center">
|
||||
<button
|
||||
onMouseEnter={() => setShowVolumeSlider(true)}
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
|
||||
{volume === 0 ? (
|
||||
<FaVolumeXmark className="w-5 h-5" />
|
||||
) : (
|
||||
<FaVolumeHigh className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{lyrics.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowLyrics(!showLyrics)}
|
||||
className={`p-2 hover:bg-gray-700/50 rounded-full transition-colors ${
|
||||
showLyrics ? 'text-primary bg-primary/20' : 'text-gray-500'
|
||||
}`}
|
||||
title={showLyrics ? 'Hide Lyrics' : 'Show Lyrics'}
|
||||
>
|
||||
<FaQuoteLeft className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showVolumeSlider && (
|
||||
<div
|
||||
className="w-24"
|
||||
onMouseLeave={() => setShowVolumeSlider(false)}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={volume * 100}
|
||||
onChange={handleVolumeChange}
|
||||
className="w-full accent-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Lyrics (Desktop Only) */}
|
||||
{showLyrics && lyrics.length > 0 && (
|
||||
<div className="flex-1 min-w-0 min-h-0 flex flex-col" ref={lyricsRef}>
|
||||
<div className="h-full flex flex-col">
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="space-y-3 pl-4 pr-4 py-4">
|
||||
{lyrics.map((line, index) => (
|
||||
<div
|
||||
key={index}
|
||||
data-lyric-index={index}
|
||||
onClick={() => handleLyricClick(line.time)}
|
||||
className={`text-base leading-relaxed transition-all duration-300 break-words cursor-pointer hover:text-foreground ${
|
||||
index === currentLyricIndex
|
||||
? 'text-foreground font-bold text-2xl'
|
||||
: index < currentLyricIndex
|
||||
? 'text-foreground/60'
|
||||
: 'text-foreground/40'
|
||||
}`}
|
||||
style={{
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
hyphens: 'auto',
|
||||
paddingBottom: '4px',
|
||||
paddingLeft: '8px'
|
||||
}}
|
||||
title={`Click to jump to ${formatTime(line.time)}`}
|
||||
>
|
||||
{line.text || '♪'}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ height: '200px' }} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,7 @@ export function PopularSongs({ songs, artistName }: PopularSongsProps) {
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 1200) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId,
|
||||
starred: !!song.starred
|
||||
@@ -95,7 +95,7 @@ export function PopularSongs({ songs, artistName }: PopularSongsProps) {
|
||||
<div className="relative w-12 h-12 bg-muted rounded-md overflow-hidden shrink-0">
|
||||
{song.coverArt && api && (
|
||||
<Image
|
||||
src={api.getCoverArtUrl(song.coverArt, 96)}
|
||||
src={api.getCoverArtUrl(song.coverArt, 300)}
|
||||
alt={song.album}
|
||||
width={48}
|
||||
height={48}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { PostHogProvider } from "../components/PostHogProvider";
|
||||
import { WhatsNewPopup } from "../components/WhatsNewPopup";
|
||||
import Ihateserverside from "./ihateserverside";
|
||||
import DynamicViewportTheme from "./DynamicViewportTheme";
|
||||
import ThemeColorHandler from "./ThemeColorHandler";
|
||||
import { useViewportThemeColor } from "@/hooks/use-viewport-theme-color";
|
||||
import { LoginForm } from "./start-screen";
|
||||
import Image from "next/image";
|
||||
|
||||
@@ -83,6 +85,7 @@ export default function RootLayoutClient({ children }: { children: React.ReactNo
|
||||
<PostHogProvider>
|
||||
<ThemeProvider>
|
||||
<DynamicViewportTheme />
|
||||
<ThemeColorHandler />
|
||||
<NavidromeConfigProvider>
|
||||
<NavidromeProvider>
|
||||
<NavidromeErrorBoundary>
|
||||
|
||||
@@ -38,7 +38,7 @@ export function SettingsManagement() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="py-5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5" />
|
||||
|
||||
@@ -153,7 +153,7 @@ export function SidebarCustomization() {
|
||||
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="py-5">
|
||||
<CardHeader>
|
||||
<CardTitle>Sidebar Customization</CardTitle>
|
||||
<CardDescription>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Song } from '@/lib/navidrome';
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Song, Album } from '@/lib/navidrome';
|
||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Play, Heart, Music, Shuffle } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { UserProfile } from './UserProfile';
|
||||
|
||||
interface SongRecommendationsProps {
|
||||
userName?: string;
|
||||
@@ -17,14 +19,26 @@ interface SongRecommendationsProps {
|
||||
export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
||||
const { api, isConnected } = useNavidrome();
|
||||
const { playTrack, shuffle, toggleShuffle } = useAudioPlayer();
|
||||
const isMobile = useIsMobile();
|
||||
const [recommendedSongs, setRecommendedSongs] = useState<Song[]>([]);
|
||||
const [recommendedAlbums, setRecommendedAlbums] = useState<Album[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [songStates, setSongStates] = useState<Record<string, boolean>>({});
|
||||
const [imageLoadingStates, setImageLoadingStates] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Get greeting based on time of day
|
||||
const hour = new Date().getHours();
|
||||
const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
|
||||
// Memoize the greeting to prevent recalculation
|
||||
const greeting = useMemo(() => {
|
||||
const hour = new Date().getHours();
|
||||
return hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
|
||||
}, []);
|
||||
|
||||
// Memoized callbacks to prevent re-renders
|
||||
const handleImageLoad = useCallback(() => {
|
||||
// Image loaded - no state update needed to prevent re-renders
|
||||
}, []);
|
||||
|
||||
const handleImageError = useCallback(() => {
|
||||
// Image error - no state update needed to prevent re-renders
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadRecommendations = async () => {
|
||||
@@ -32,43 +46,47 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Get random albums and extract songs from them
|
||||
const randomAlbums = await api.getAlbums('random', 10); // Get 10 random albums
|
||||
const allSongs: Song[] = [];
|
||||
// Get random albums for both mobile album view and desktop song extraction
|
||||
const randomAlbums = await api.getAlbums('random', 10);
|
||||
|
||||
// Get songs from first few albums
|
||||
for (let i = 0; i < Math.min(3, randomAlbums.length); i++) {
|
||||
try {
|
||||
const albumSongs = await api.getAlbumSongs(randomAlbums[i].id);
|
||||
allSongs.push(...albumSongs);
|
||||
} catch (error) {
|
||||
console.error('Failed to get album songs:', error);
|
||||
if (isMobile) {
|
||||
// For mobile: show 6 random albums
|
||||
setRecommendedAlbums(randomAlbums.slice(0, 6));
|
||||
} else {
|
||||
// For desktop: extract songs from albums (original behavior)
|
||||
const allSongs: Song[] = [];
|
||||
|
||||
// Get songs from first few albums
|
||||
for (let i = 0; i < Math.min(3, randomAlbums.length); i++) {
|
||||
try {
|
||||
const albumSongs = await api.getAlbumSongs(randomAlbums[i].id);
|
||||
allSongs.push(...albumSongs);
|
||||
} catch (error) {
|
||||
console.error('Failed to get album songs:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Shuffle and limit to 6 songs
|
||||
const shuffled = allSongs.sort(() => Math.random() - 0.5);
|
||||
const recommendations = shuffled.slice(0, 6);
|
||||
setRecommendedSongs(recommendations);
|
||||
|
||||
// Initialize starred states for songs
|
||||
const states: Record<string, boolean> = {};
|
||||
recommendations.forEach((song: Song) => {
|
||||
states[song.id] = !!song.starred;
|
||||
});
|
||||
setSongStates(states);
|
||||
}
|
||||
|
||||
// Shuffle and limit to 6 songs
|
||||
const shuffled = allSongs.sort(() => Math.random() - 0.5);
|
||||
const recommendations = shuffled.slice(0, 6);
|
||||
setRecommendedSongs(recommendations);
|
||||
|
||||
// Initialize starred states and image loading states
|
||||
const states: Record<string, boolean> = {};
|
||||
const imageStates: Record<string, boolean> = {};
|
||||
recommendations.forEach((song: Song) => {
|
||||
states[song.id] = !!song.starred;
|
||||
imageStates[song.id] = true; // Start with loading state
|
||||
});
|
||||
setSongStates(states);
|
||||
setImageLoadingStates(imageStates);
|
||||
} catch (error) {
|
||||
console.error('Failed to load song recommendations:', error);
|
||||
console.error('Failed to load recommendations:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadRecommendations();
|
||||
}, [api, isConnected]);
|
||||
}, [api, isConnected, isMobile]);
|
||||
|
||||
const handlePlaySong = async (song: Song) => {
|
||||
if (!api) return;
|
||||
@@ -83,7 +101,7 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
||||
album: song.album || 'Unknown Album',
|
||||
albumId: song.albumId || '',
|
||||
duration: song.duration || 0,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined,
|
||||
starred: !!song.starred
|
||||
};
|
||||
await playTrack(track, true);
|
||||
@@ -92,17 +110,50 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayAlbum = async (album: Album) => {
|
||||
if (!api) return;
|
||||
|
||||
try {
|
||||
// Get album songs and play the first one
|
||||
const albumSongs = await api.getAlbumSongs(album.id);
|
||||
if (albumSongs.length > 0) {
|
||||
const track = {
|
||||
id: albumSongs[0].id,
|
||||
name: albumSongs[0].title,
|
||||
url: api.getStreamUrl(albumSongs[0].id),
|
||||
artist: albumSongs[0].artist || 'Unknown Artist',
|
||||
artistId: albumSongs[0].artistId || '',
|
||||
album: albumSongs[0].album || 'Unknown Album',
|
||||
albumId: albumSongs[0].albumId || '',
|
||||
duration: albumSongs[0].duration || 0,
|
||||
coverArt: albumSongs[0].coverArt ? api.getCoverArtUrl(albumSongs[0].coverArt, 64) : undefined,
|
||||
starred: !!albumSongs[0].starred
|
||||
};
|
||||
await playTrack(track, true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to play album:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShuffleAll = async () => {
|
||||
if (recommendedSongs.length === 0) return;
|
||||
if (isMobile && recommendedAlbums.length === 0) return;
|
||||
if (!isMobile && recommendedSongs.length === 0) return;
|
||||
|
||||
// Enable shuffle if not already on
|
||||
if (!shuffle) {
|
||||
toggleShuffle();
|
||||
}
|
||||
|
||||
// Play a random song from recommendations
|
||||
const randomSong = recommendedSongs[Math.floor(Math.random() * recommendedSongs.length)];
|
||||
await handlePlaySong(randomSong);
|
||||
if (isMobile) {
|
||||
// Play a random album
|
||||
const randomAlbum = recommendedAlbums[Math.floor(Math.random() * recommendedAlbums.length)];
|
||||
await handlePlayAlbum(randomAlbum);
|
||||
} else {
|
||||
// Play a random song from recommendations
|
||||
const randomSong = recommendedSongs[Math.floor(Math.random() * recommendedSongs.length)];
|
||||
await handlePlaySong(randomSong);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (duration: number): string => {
|
||||
@@ -118,11 +169,19 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
||||
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
|
||||
<div className="h-4 w-64 bg-muted animate-pulse rounded" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-16 bg-muted animate-pulse rounded" />
|
||||
))}
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="aspect-square bg-muted animate-pulse rounded" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-16 bg-muted animate-pulse rounded" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -135,95 +194,153 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
||||
{greeting}{userName ? `, ${userName}` : ''}!
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Here are some songs you might enjoy
|
||||
{isMobile ? 'Here are some albums you might enjoy' : 'Here are some songs you might enjoy'}
|
||||
</p>
|
||||
</div>
|
||||
{recommendedSongs.length > 0 && (
|
||||
<Button onClick={handleShuffleAll} variant="outline" size="sm">
|
||||
<Shuffle className="w-4 h-4 mr-2" />
|
||||
Shuffle All
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Mobile User Profile */}
|
||||
{isMobile && <UserProfile variant="mobile" />}
|
||||
|
||||
{/* Shuffle All Button (Desktop only) */}
|
||||
{(isMobile ? recommendedAlbums.length > 0 : recommendedSongs.length > 0) && !isMobile && (
|
||||
<Button onClick={handleShuffleAll} variant="outline" size="sm">
|
||||
<Shuffle className="w-4 h-4 mr-2" />
|
||||
Shuffle All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recommendedSongs.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{recommendedSongs.map((song) => (
|
||||
<Card
|
||||
key={song.id}
|
||||
className="group cursor-pointer hover:bg-accent/50 transition-colors py-2"
|
||||
onClick={() => handlePlaySong(song)}
|
||||
>
|
||||
<CardContent className="px-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-12 h-12 rounded overflow-hidden bg-muted flex-shrink-0">
|
||||
{song.coverArt && api ? (
|
||||
<>
|
||||
{imageLoadingStates[song.id] && (
|
||||
<div className="absolute inset-0 bg-muted flex items-center justify-center">
|
||||
<Music className="w-6 h-6 text-muted-foreground animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
<Image
|
||||
src={api.getCoverArtUrl(song.coverArt, 100)}
|
||||
alt={song.title}
|
||||
fill
|
||||
className={`object-cover transition-opacity duration-300 ${
|
||||
imageLoadingStates[song.id] ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
sizes="48px"
|
||||
onLoad={() => setImageLoadingStates(prev => ({ ...prev, [song.id]: false }))}
|
||||
onError={() => setImageLoadingStates(prev => ({ ...prev, [song.id]: false }))}
|
||||
/>
|
||||
</>
|
||||
{isMobile ? (
|
||||
/* Mobile: Show albums in 3x2 grid */
|
||||
recommendedAlbums.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{recommendedAlbums.map((album) => (
|
||||
<div key={album.id} className="space-y-2">
|
||||
<Link
|
||||
href={`/album/${album.id}`}
|
||||
className="group cursor-pointer block"
|
||||
>
|
||||
<div className="relative aspect-square rounded-lg overflow-hidden bg-muted">
|
||||
{album.coverArt && api ? (
|
||||
<Image
|
||||
src={api.getCoverArtUrl(album.coverArt, 300)}
|
||||
alt={album.name}
|
||||
width={600}
|
||||
height={600}
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 33vw, 200px"
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Music className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{!imageLoadingStates[song.id] && (
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Play className="w-4 h-4 text-white" />
|
||||
<Music className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{song.title}</p>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link
|
||||
href={`/artist/${song.artistId}`}
|
||||
className="hover:underline truncate"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{song.artist}
|
||||
</Link>
|
||||
{song.duration && (
|
||||
</Link>
|
||||
<div className="space-y-1">
|
||||
<Link
|
||||
href={`/album/${album.id}`}
|
||||
className="font-medium text-sm truncate hover:underline block"
|
||||
>
|
||||
{album.name}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/artist/${album.artistId || album.artist}`}
|
||||
className="text-xs text-muted-foreground truncate hover:underline block"
|
||||
>
|
||||
{album.artist}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<Music className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">
|
||||
No albums available for recommendations
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
) : (
|
||||
/* Desktop: Show songs in original format */
|
||||
recommendedSongs.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{recommendedSongs.map((song) => (
|
||||
<Card
|
||||
key={song.id}
|
||||
className="group cursor-pointer hover:bg-accent/50 transition-colors py-2"
|
||||
onClick={() => handlePlaySong(song)}
|
||||
>
|
||||
<CardContent className="px-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-12 h-12 rounded overflow-hidden bg-muted flex-shrink-0">
|
||||
{song.coverArt && api ? (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{formatDuration(song.duration)}</span>
|
||||
<Image
|
||||
src={api.getCoverArtUrl(song.coverArt, 48)}
|
||||
alt={song.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="48px"
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<Play className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Music className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{song.title}</p>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link
|
||||
href={`/artist/${song.artistId}`}
|
||||
className="hover:underline truncate"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{song.artist}
|
||||
</Link>
|
||||
{song.duration && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{formatDuration(song.duration)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{songStates[song.id] && (
|
||||
<Heart className="w-4 h-4 text-primary flex-shrink-0" fill="currentColor" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{songStates[song.id] && (
|
||||
<Heart className="w-4 h-4 text-primary flex-shrink-0" fill="currentColor" />
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<Music className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">
|
||||
No songs available for recommendations
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<Music className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">
|
||||
No songs available for recommendations
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
8
app/components/ThemeColorHandler.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useViewportThemeColor } from '@/hooks/use-viewport-theme-color';
|
||||
|
||||
export default function ThemeColorHandler() {
|
||||
useViewportThemeColor();
|
||||
return null;
|
||||
}
|
||||
209
app/components/UserProfile.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { User, ChevronDown, Settings, LogOut } from 'lucide-react';
|
||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||
import { getGravatarUrl } from '@/lib/gravatar';
|
||||
import { User as NavidromeUser } from '@/lib/navidrome';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface UserProfileProps {
|
||||
variant?: 'desktop' | 'mobile';
|
||||
}
|
||||
|
||||
export function UserProfile({ variant = 'desktop' }: UserProfileProps) {
|
||||
const { api, isConnected } = useNavidrome();
|
||||
const [userInfo, setUserInfo] = useState<NavidromeUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserInfo = async () => {
|
||||
if (!api || !isConnected) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await api.getUserInfo();
|
||||
setUserInfo(user);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user info:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserInfo();
|
||||
}, [api, isConnected]);
|
||||
|
||||
const handleLogout = () => {
|
||||
// Clear Navidrome config and reload
|
||||
localStorage.removeItem('navidrome-config');
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
if (!userInfo) {
|
||||
if (variant === 'desktop') {
|
||||
return (
|
||||
<Link href="/settings">
|
||||
<Button variant="ghost" size="sm" className="gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Link href="/settings">
|
||||
<Button variant="ghost" size="sm" className="gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const gravatarUrl = userInfo.email
|
||||
? getGravatarUrl(userInfo.email, variant === 'desktop' ? 32 : 48, 'identicon')
|
||||
: null;
|
||||
|
||||
if (variant === 'desktop') {
|
||||
// Desktop: Only show profile icon
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="flex items-center gap-1 h-auto p-2">
|
||||
{gravatarUrl ? (
|
||||
<Image
|
||||
src={gravatarUrl}
|
||||
alt={`${userInfo.username}'s avatar`}
|
||||
width={16}
|
||||
height={16}
|
||||
className="rounded-full"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<div className="flex items-center gap-2 p-2">
|
||||
{gravatarUrl ? (
|
||||
<Image
|
||||
src={gravatarUrl}
|
||||
alt={`${userInfo.username}'s avatar`}
|
||||
width={16}
|
||||
height={16}
|
||||
className="rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<User className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium">{userInfo.username}</p>
|
||||
{userInfo.email && (
|
||||
<p className="text-xs text-muted-foreground">{userInfo.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings" className="flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 text-red-600 focus:text-red-600"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
} else {
|
||||
// Mobile: Show only icon with dropdown
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="flex items-center gap-1 h-auto p-2">
|
||||
{gravatarUrl ? (
|
||||
<Image
|
||||
src={gravatarUrl}
|
||||
alt={`${userInfo.username}'s avatar`}
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-full"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<div className="flex items-center gap-2 p-2">
|
||||
{gravatarUrl ? (
|
||||
<Image
|
||||
src={gravatarUrl}
|
||||
alt={`${userInfo.username}'s avatar`}
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<User className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium">{userInfo.username}</p>
|
||||
{userInfo.email && (
|
||||
<p className="text-xs text-muted-foreground">{userInfo.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings" className="flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 text-red-600 focus:text-red-600"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
// Current app version from package.json
|
||||
const APP_VERSION = '2025.07.10';
|
||||
const APP_VERSION = '2025.07.31';
|
||||
|
||||
// Changelog data - add new versions at the top
|
||||
const CHANGELOG = [
|
||||
{
|
||||
version: '2025.07.31',
|
||||
title: 'July End of Month Update',
|
||||
changes: [
|
||||
'Native support for moblie devices (using pwa)',
|
||||
],
|
||||
fixes: [
|
||||
'Fixed issue with mobile navigation bar not displaying correctly',
|
||||
'Improved performance on mobile devices',
|
||||
'Resolved layout issues on smaller screens',
|
||||
'Fixed audio player controls not responding on mobile',
|
||||
'Improved touch interactions for better usability',
|
||||
'Fixed issue with album artwork not loading on mobile',
|
||||
'Resolved bug with search functionality on mobile devices',
|
||||
'Improved caching for faster load times on mobile',
|
||||
],
|
||||
breaking: [
|
||||
]
|
||||
},
|
||||
{
|
||||
version: '2025.07.10',
|
||||
title: 'July Major Update',
|
||||
@@ -189,65 +206,86 @@ export function WhatsNewPopup() {
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh]">
|
||||
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||
<div>
|
||||
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
What's New in Mice
|
||||
<Badge variant="outline">
|
||||
{tab === 'latest' ? currentVersionChangelog.version : archiveChangelog?.version}
|
||||
</Badge>
|
||||
</DialogTitle>
|
||||
<>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* Dialog content */}
|
||||
<div className="relative bg-background rounded-lg shadow-lg max-w-2xl w-full mx-4 max-h-[80vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex flex-row items-center justify-between space-y-0 p-6 pb-4 shrink-0">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
What's New in Mice
|
||||
<Badge variant="outline">
|
||||
{tab === 'latest' ? currentVersionChangelog.version : archiveChangelog?.version}
|
||||
</Badge>
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 px-6 pt-4 shrink-0">
|
||||
<Button
|
||||
variant={tab === 'latest' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTab('latest')}
|
||||
>
|
||||
Latest
|
||||
</Button>
|
||||
<Button
|
||||
variant={tab === 'archive' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTab('archive')}
|
||||
disabled={archiveChangelogs.length === 0}
|
||||
>
|
||||
Archive
|
||||
</Button>
|
||||
{tab === 'archive' && archiveChangelogs.length > 0 && (
|
||||
<select
|
||||
className="ml-2 border rounded px-2 py-1 text-sm bg-background"
|
||||
value={selectedArchive}
|
||||
onChange={e => setSelectedArchive(e.target.value)}
|
||||
>
|
||||
{archiveChangelogs.map(entry => (
|
||||
<option key={entry.version} value={entry.version}>
|
||||
{entry.version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
|
||||
<div className="space-y-6">
|
||||
{tab === 'latest'
|
||||
? renderChangelog(currentVersionChangelog)
|
||||
: archiveChangelog && renderChangelog(archiveChangelog)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer button */}
|
||||
<div className="flex justify-center p-6 pt-4 shrink-0">
|
||||
<Button onClick={handleClose}>
|
||||
Got it!
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Tabs */}
|
||||
<>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Button
|
||||
variant={tab === 'latest' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTab('latest')}
|
||||
>
|
||||
Latest
|
||||
</Button>
|
||||
<Button
|
||||
variant={tab === 'archive' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTab('archive')}
|
||||
disabled={archiveChangelogs.length === 0}
|
||||
>
|
||||
Archive
|
||||
</Button>
|
||||
{tab === 'archive' && archiveChangelogs.length > 0 && (
|
||||
<select
|
||||
className="ml-2 border rounded px-2 py-1 text-sm"
|
||||
value={selectedArchive}
|
||||
onChange={e => setSelectedArchive(e.target.value)}
|
||||
>
|
||||
{archiveChangelogs.map(entry => (
|
||||
<option key={entry.version} value={entry.version}>
|
||||
{entry.version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea className="max-h-[60vh] pr-4">
|
||||
{tab === 'latest'
|
||||
? renderChangelog(currentVersionChangelog)
|
||||
: archiveChangelog && renderChangelog(archiveChangelog)}
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button onClick={handleClose}>
|
||||
Got it!
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useNavidrome } from "./NavidromeContext"
|
||||
import Link from "next/link";
|
||||
import { useAudioPlayer, Track } from "@/app/components/AudioPlayerContext";
|
||||
import { getNavidromeAPI } from "@/lib/navidrome";
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ArtistIcon } from "@/app/components/artist-icon";
|
||||
@@ -46,8 +46,24 @@ export function AlbumArtwork({
|
||||
const router = useRouter();
|
||||
const { addAlbumToQueue, playTrack, addToQueue } = useAudioPlayer();
|
||||
const { playlists, starItem, unstarItem } = useNavidrome();
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
// Memoize cover art URL with dynamic sizing
|
||||
const coverArtUrl = useMemo(() => {
|
||||
if (!api || !album.coverArt) return '/default-user.jpg';
|
||||
|
||||
// Use width prop or default size for optimization
|
||||
const imageSize = width || height || 300;
|
||||
return api.getCoverArtUrl(album.coverArt, imageSize);
|
||||
}, [api, album.coverArt, width, height]);
|
||||
|
||||
// Use callback to prevent function recreation on every render
|
||||
const handleImageLoad = useCallback(() => {
|
||||
// Image loaded successfully - no state update needed
|
||||
}, []);
|
||||
|
||||
const handleImageError = useCallback(() => {
|
||||
// Image failed to load - could set error state if needed
|
||||
}, []);
|
||||
|
||||
const handleClick = () => {
|
||||
router.push(`/album/${album.id}`);
|
||||
@@ -80,7 +96,7 @@ export function AlbumArtwork({
|
||||
artistId: song.artistId,
|
||||
url: api.getStreamUrl(song.id),
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt) : undefined,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 1200) : undefined,
|
||||
starred: !!song.starred
|
||||
}));
|
||||
|
||||
@@ -105,68 +121,42 @@ export function AlbumArtwork({
|
||||
console.error('Failed to toggle favorite:', error);
|
||||
}
|
||||
};
|
||||
// Get cover art URL with proper fallback
|
||||
const coverArtUrl = album.coverArt && api
|
||||
? api.getCoverArtUrl(album.coverArt, 300)
|
||||
: '/default-user.jpg';
|
||||
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)} {...props}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<Card key={album.id} className="overflow-hidden cursor-pointer px-0 py-0 gap-0" onClick={() => handleClick()}>
|
||||
<div className="aspect-square relative group">
|
||||
{album.coverArt && api ? (
|
||||
<>
|
||||
{imageLoading && (
|
||||
<div className="absolute inset-0 bg-muted animate-pulse rounded flex items-center justify-center">
|
||||
<Disc className="w-12 h-12 text-muted-foreground animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<Image
|
||||
src={api.getCoverArtUrl(album.coverArt)}
|
||||
alt={album.name}
|
||||
fill
|
||||
className={`w-full h-full object-cover transition-opacity duration-300 ${
|
||||
imageLoading ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
sizes="(max-width: 768px) 100vw, 300px"
|
||||
onLoad={() => setImageLoading(false)}
|
||||
onError={() => {
|
||||
setImageLoading(false);
|
||||
setImageError(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full bg-muted rounded flex items-center justify-center">
|
||||
<Disc className="w-12 h-12 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{!imageLoading && (
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||
<Play className="w-6 h-6 mx-auto hidden group-hover:block" onClick={() => handlePlayAlbum(album)}/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardContent className="p-4">
|
||||
{imageLoading ? (
|
||||
<>
|
||||
<div className="h-5 w-3/4 bg-muted animate-pulse rounded mb-2" />
|
||||
<div className="h-4 w-1/2 bg-muted animate-pulse rounded mb-1" />
|
||||
<div className="h-3 w-2/3 bg-muted animate-pulse rounded" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="font-semibold truncate">{album.name}</h3>
|
||||
<p className="text-sm text-muted-foreground truncate " onClick={() => router.push(album.artistId)}>{album.artist}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{album.songCount} songs • {Math.floor(album.duration / 60)} min
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="aspect-square relative group">
|
||||
{album.coverArt && api ? (
|
||||
<Image
|
||||
src={coverArtUrl}
|
||||
alt={album.name}
|
||||
fill
|
||||
className="w-full h-full object-cover transition-all"
|
||||
sizes="(max-width: 768px) 100vw, 300px"
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
priority={false}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-muted rounded flex items-center justify-center">
|
||||
<Disc className="w-12 h-12 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||
<Play className="w-6 h-6 mx-auto hidden group-hover:block" onClick={() => handlePlayAlbum(album)}/>
|
||||
</div>
|
||||
</div>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-semibold truncate">{album.name}</h3>
|
||||
<p className="text-sm text-muted-foreground truncate " onClick={() => router.push(album.artistId)}>{album.artist}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{album.songCount} songs • {Math.floor(album.duration / 60)} min
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* <div onClick={handleClick} className="overflow-hidden rounded-md">
|
||||
<Image
|
||||
src={coverArtUrl}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Menu } from "@/app/components/menu";
|
||||
import { Sidebar } from "@/app/components/sidebar";
|
||||
import { useNavidrome } from "@/app/components/NavidromeContext";
|
||||
import { AudioPlayer } from "./AudioPlayer";
|
||||
import { BottomNavigation } from './BottomNavigation';
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { useFavoriteAlbums } from "@/hooks/use-favorite-albums";
|
||||
|
||||
@@ -96,48 +97,74 @@ const Ihateserverside: React.FC<IhateserversideProps> = ({ children }) => {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hidden md:flex md:flex-col md:h-screen md:w-screen md:overflow-hidden">
|
||||
{/* Top Menu */}
|
||||
<div
|
||||
className="sticky z-10 bg-background border-b w-full"
|
||||
style={{
|
||||
left: 'env(titlebar-area-x, 0)',
|
||||
top: 'env(titlebar-area-y, 0)',
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
toggleSidebar={toggleSidebarVisibility}
|
||||
isSidebarVisible={isSidebarVisible}
|
||||
toggleStatusBar={() => setIsStatusBarVisible(!isStatusBarVisible)}
|
||||
isStatusBarVisible={isStatusBarVisible}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
{/* Mobile Layout */}
|
||||
<div className="flex md:hidden flex-col h-screen w-screen overflow-hidden">
|
||||
{/* Top Menu */}
|
||||
{/* <div className="shrink-0 bg-background border-b w-full">
|
||||
<Menu
|
||||
toggleSidebar={toggleSidebarVisibility}
|
||||
isSidebarVisible={isSidebarVisible}
|
||||
toggleStatusBar={() => setIsStatusBarVisible(!isStatusBarVisible)}
|
||||
isStatusBarVisible={isStatusBarVisible}
|
||||
/>
|
||||
</div> */}
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex overflow-hidden w-full">
|
||||
{isSidebarVisible && (
|
||||
<div className="w-16 shrink-0 border-r transition-all duration-200">
|
||||
<Sidebar
|
||||
playlists={playlists}
|
||||
className="h-full overflow-y-auto"
|
||||
visible={isSidebarVisible}
|
||||
favoriteAlbums={favoriteAlbums}
|
||||
onRemoveFavoriteAlbum={removeFavoriteAlbum}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto min-w-0">
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
{/* Main Content Area with bottom padding for audio player and bottom nav */}
|
||||
<div className="flex-1 overflow-y-auto pb-40">
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Audio Player */}
|
||||
{isStatusBarVisible && (
|
||||
<AudioPlayer />
|
||||
)}
|
||||
<Toaster />
|
||||
</div>
|
||||
{/* Bottom Navigation for Mobile */}
|
||||
<BottomNavigation />
|
||||
|
||||
<Toaster />
|
||||
</div>
|
||||
|
||||
{/* Desktop Layout */}
|
||||
<div className="hidden md:flex md:flex-col md:h-screen md:w-screen md:overflow-hidden">
|
||||
{/* Top Menu */}
|
||||
<div
|
||||
className="sticky z-10 bg-background border-b w-full"
|
||||
style={{
|
||||
left: 'env(titlebar-area-x, 0)',
|
||||
top: 'env(titlebar-area-y, 0)',
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
toggleSidebar={toggleSidebarVisibility}
|
||||
isSidebarVisible={isSidebarVisible}
|
||||
toggleStatusBar={() => setIsStatusBarVisible(!isStatusBarVisible)}
|
||||
isStatusBarVisible={isStatusBarVisible}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex overflow-hidden w-full">
|
||||
{isSidebarVisible && (
|
||||
<div className="w-16 shrink-0 border-r transition-all duration-200">
|
||||
<Sidebar
|
||||
playlists={playlists}
|
||||
className="h-full overflow-y-auto"
|
||||
visible={isSidebarVisible}
|
||||
favoriteAlbums={favoriteAlbums}
|
||||
onRemoveFavoriteAlbum={removeFavoriteAlbum}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto min-w-0">
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Toaster />
|
||||
</div>
|
||||
|
||||
{/* Single Shared Audio Player - shows on all layouts */}
|
||||
<AudioPlayer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback } from "react";
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Image from "next/image";
|
||||
import { Github, Mail } from "lucide-react"
|
||||
import { Github, Mail, Menu as MenuIcon, X } from "lucide-react"
|
||||
import { UserProfile } from "@/app/components/UserProfile";
|
||||
import {
|
||||
Menubar,
|
||||
MenubarCheckboxItem,
|
||||
@@ -28,9 +29,35 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
Search,
|
||||
Home,
|
||||
List,
|
||||
Radio,
|
||||
Users,
|
||||
Disc,
|
||||
Music,
|
||||
Heart,
|
||||
Grid3X3,
|
||||
Clock,
|
||||
Settings,
|
||||
Circle
|
||||
} from "lucide-react";
|
||||
|
||||
interface MenuProps {
|
||||
toggleSidebar: () => void;
|
||||
@@ -43,9 +70,27 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
|
||||
const [isFullScreen, setIsFullScreen] = useState(false)
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const { isConnected } = useNavidrome();
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [navidromeUrl, setNavidromeUrl] = useState<string | null>(null);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Navigation items for mobile menu
|
||||
const navigationItems = [
|
||||
{ href: '/', label: 'Home', icon: Home },
|
||||
{ href: '/search', label: 'Search', icon: Search },
|
||||
{ href: '/library/albums', label: 'Albums', icon: Disc },
|
||||
{ href: '/library/artists', label: 'Artists', icon: Users },
|
||||
{ href: '/library/songs', label: 'Songs', icon: Circle },
|
||||
{ href: '/library/playlists', label: 'Playlists', icon: Music },
|
||||
{ href: '/favorites', label: 'Favorites', icon: Heart },
|
||||
{ href: '/queue', label: 'Queue', icon: List },
|
||||
{ href: '/radio', label: 'Radio', icon: Radio },
|
||||
{ href: '/browse', label: 'Browse', icon: Grid3X3 },
|
||||
{ href: '/history', label: 'History', icon: Clock },
|
||||
{ href: '/settings', label: 'Settings', icon: Settings },
|
||||
];
|
||||
|
||||
// For this demo, we'll show connection status instead of user auth
|
||||
const connectionStatus = isConnected ? "Connected to Navidrome" : "Not connected";
|
||||
@@ -112,28 +157,35 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<Menubar
|
||||
className="rounded-none border-b border-none px-2 lg:px-2 flex-1 min-w-0"
|
||||
style={{
|
||||
minWidth: 0,
|
||||
WebkitAppRegion: "drag"
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<div style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} className="flex items-center gap-2">
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="font-bold">mice</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onClick={() => setOpen(true)}>About Music</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem onClick={() => router.push('/settings')}>
|
||||
Preferences <MenubarShortcut>⌘,</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem onClick={() => isClient && window.close()}>
|
||||
Quit Music <MenubarShortcut>⌘Q</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
{/* Mobile Top Bar - Simplified since navigation is now at bottom */}
|
||||
{isMobile ? (
|
||||
// hey bear!
|
||||
// nothing
|
||||
null
|
||||
) : (
|
||||
/* Desktop Navigation */
|
||||
<Menubar
|
||||
className="rounded-none border-b border-none px-2 lg:px-2 flex-1 min-w-0"
|
||||
style={{
|
||||
minWidth: 0,
|
||||
WebkitAppRegion: "drag"
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<div style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} className="flex items-center gap-2">
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="font-bold">mice</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onClick={() => setOpen(true)}>About Music</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem onClick={() => router.push('/settings')}>
|
||||
Preferences <MenubarShortcut>⌘,</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem onClick={() => isClient && window.close()}>
|
||||
Quit Music <MenubarShortcut>⌘Q</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="relative">File</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
@@ -279,6 +331,14 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
|
||||
</MenubarMenu>
|
||||
</div>
|
||||
</Menubar>
|
||||
)}
|
||||
|
||||
{/* User Profile - Desktop only */}
|
||||
{!isMobile && (
|
||||
<div className="ml-auto">
|
||||
<UserProfile variant="desktop" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ export function Sidebar({ className, playlists, visible = true, favoriteAlbums =
|
||||
>
|
||||
{album.coverArt && api ? (
|
||||
<Image
|
||||
src={api.getCoverArtUrl(album.coverArt, 32)}
|
||||
src={api.getCoverArtUrl(album.coverArt, 150)}
|
||||
alt={album.name}
|
||||
width={16}
|
||||
height={16}
|
||||
@@ -165,7 +165,7 @@ export function Sidebar({ className, playlists, visible = true, favoriteAlbums =
|
||||
>
|
||||
{album.coverArt && api ? (
|
||||
<Image
|
||||
src={api.getCoverArtUrl(album.coverArt, 32)}
|
||||
src={api.getCoverArtUrl(album.coverArt, 150)}
|
||||
alt={album.name}
|
||||
width={16}
|
||||
height={16}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { useNavidromeConfig } from '@/app/components/NavidromeConfigContext';
|
||||
import { useTheme } from '@/app/components/ThemeProvider';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaPalette, FaLastfm, FaBars } from 'react-icons/fa';
|
||||
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaPalette, FaLastfm } from 'react-icons/fa';
|
||||
|
||||
export function LoginForm({
|
||||
className,
|
||||
@@ -45,20 +45,7 @@ export function LoginForm({
|
||||
return true;
|
||||
});
|
||||
|
||||
// New settings
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('sidebar-collapsed') === 'true';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const [standaloneLastfmEnabled, setStandaloneLastfmEnabled] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('standalone-lastfm-enabled') === 'true';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
// New settings - removed sidebar and standalone lastfm options
|
||||
|
||||
// Check if Navidrome is configured via environment variables
|
||||
const hasEnvConfig = React.useMemo(() => {
|
||||
@@ -187,8 +174,6 @@ export function LoginForm({
|
||||
const handleFinishSetup = () => {
|
||||
// Save all settings
|
||||
localStorage.setItem('lastfm-scrobbling-enabled', scrobblingEnabled.toString());
|
||||
localStorage.setItem('sidebar-collapsed', sidebarCollapsed.toString());
|
||||
localStorage.setItem('standalone-lastfm-enabled', standaloneLastfmEnabled.toString());
|
||||
|
||||
// Mark onboarding as complete
|
||||
localStorage.setItem('onboarding-completed', '1.1.0');
|
||||
@@ -252,7 +237,7 @@ export function LoginForm({
|
||||
if (step === 'settings') {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card>
|
||||
<Card className='py-5'>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FaPalette className="w-5 h-5" />
|
||||
@@ -286,29 +271,6 @@ export function LoginForm({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Settings */}
|
||||
<div className="grid gap-3">
|
||||
<Label className="flex items-center gap-2">
|
||||
<FaBars className="w-4 h-4" />
|
||||
Sidebar Layout
|
||||
</Label>
|
||||
<Select
|
||||
value={sidebarCollapsed ? "collapsed" : "expanded"}
|
||||
onValueChange={(value) => setSidebarCollapsed(value === "collapsed")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="expanded">Expanded (with labels)</SelectItem>
|
||||
<SelectItem value="collapsed">Collapsed (icons only)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You can always toggle this later using the button in the sidebar
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Last.fm Scrobbling */}
|
||||
<div className="grid gap-3">
|
||||
<Label className="flex items-center gap-2">
|
||||
@@ -334,31 +296,6 @@ export function LoginForm({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Standalone Last.fm */}
|
||||
<div className="grid gap-3">
|
||||
<Label className="flex items-center gap-2">
|
||||
<FaLastfm className="w-4 h-4" />
|
||||
Standalone Last.fm (Advanced)
|
||||
</Label>
|
||||
<Select
|
||||
value={standaloneLastfmEnabled ? "enabled" : "disabled"}
|
||||
onValueChange={(value) => setStandaloneLastfmEnabled(value === "enabled")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="enabled">Enabled</SelectItem>
|
||||
<SelectItem value="disabled">Disabled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{standaloneLastfmEnabled
|
||||
? "Direct Last.fm API integration (configure in Settings later)"
|
||||
: "Use only Navidrome's Last.fm integration"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button onClick={handleFinishSetup} className="w-full">
|
||||
<FaCheck className="w-4 h-4 mr-2" />
|
||||
@@ -383,7 +320,7 @@ export function LoginForm({
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card>
|
||||
<Card className="py-5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FaServer className="w-5 h-5" />
|
||||
|
||||
@@ -58,7 +58,7 @@ const FavoritesPage = () => {
|
||||
artistId: song.artistId,
|
||||
url: api?.getStreamUrl(song.id) || '',
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api?.getCoverArtUrl(song.coverArt) : undefined,
|
||||
coverArt: song.coverArt ? api?.getCoverArtUrl(song.coverArt, 1200) : undefined,
|
||||
starred: !!song.starred
|
||||
});
|
||||
};
|
||||
@@ -78,7 +78,7 @@ const FavoritesPage = () => {
|
||||
artistId: song.artistId,
|
||||
url: api.getStreamUrl(song.id),
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt) : undefined,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 1200) : undefined,
|
||||
starred: !!song.starred
|
||||
}));
|
||||
|
||||
@@ -201,7 +201,7 @@ const FavoritesPage = () => {
|
||||
<div className="w-12 h-12 relative shrink-0">
|
||||
{song.coverArt && api ? (
|
||||
<Image
|
||||
src={api.getCoverArtUrl(song.coverArt)}
|
||||
src={api.getCoverArtUrl(song.coverArt, 1200)}
|
||||
alt={song.album}
|
||||
fill
|
||||
className="rounded object-cover"
|
||||
|
||||
198
app/globals.css
@@ -88,6 +88,18 @@
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* Hide scrollbars on mobile */
|
||||
@media (max-width: 768px) {
|
||||
* {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* Internet Explorer 10+ */
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
display: none; /* Safari and Chrome */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@@ -816,34 +828,170 @@
|
||||
---break---
|
||||
*/
|
||||
|
||||
/*
|
||||
|
||||
will delete after the new theme replaces the old one
|
||||
since the new theme already has the sidebar colors defined
|
||||
|
||||
:root {
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
||||
--sidebar-primary: hsl(240 5.9% 10%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 98%);
|
||||
--sidebar-accent: hsl(240 4.8% 95.9%);
|
||||
--sidebar-accent-foreground: hsl(240 5.9% 10%);
|
||||
--sidebar-border: hsl(220 13% 91%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
/* Mobile-specific optimizations */
|
||||
@media (max-width: 767px) {
|
||||
/* Improve touch targets for mobile */
|
||||
button {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* Better touch feedback */
|
||||
button:active {
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
/* Ensure proper viewport behavior */
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Smooth scrolling for mobile */
|
||||
.overflow-y-auto {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Mobile audio player specific */
|
||||
.mobile-audio-player {
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
/* Prevent horizontal scroll */
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.dark {
|
||||
--sidebar: hsl(240 5.9% 10%);
|
||||
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-primary: hsl(224.3 76.3% 48%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||
--sidebar-accent: hsl(240 3.7% 15.9%);
|
||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-border: hsl(240 3.7% 15.9%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
} */
|
||||
/* Safe area support for mobile devices */
|
||||
.pb-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0.5rem);
|
||||
}
|
||||
|
||||
.mobile-safe-bottom {
|
||||
margin-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
|
||||
/* Touch-optimized navigation */
|
||||
.touch-manipulation {
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Bottom navigation z-index fix */
|
||||
.bottom-nav {
|
||||
z-index: 45;
|
||||
}
|
||||
|
||||
/* Audio player above bottom nav */
|
||||
.mobile-audio-above-nav {
|
||||
z-index: 50;
|
||||
bottom: calc(4rem + env(safe-area-inset-bottom, 0));
|
||||
}
|
||||
|
||||
/* Mobile Audio Player Styles */
|
||||
.mobile-audio-player {
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.mobile-audio-player button {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
/* Prevent iOS zoom on input focus */
|
||||
@media screen and (max-width: 767px) {
|
||||
input[type="range"] {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Improve button touch targets */
|
||||
.mobile-audio-player button {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Better focus states for accessibility */
|
||||
button:focus-visible {
|
||||
outline: 2px solid hsl(var(--primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Improved animations */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Safe area insets for mobile devices */
|
||||
@supports (padding: max(0px)) {
|
||||
.mobile-safe-bottom {
|
||||
padding-bottom: max(1rem, env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.mobile-safe-top {
|
||||
padding-top: max(0.5rem, env(safe-area-inset-top));
|
||||
}
|
||||
}
|
||||
|
||||
/* Progress bar improvements for mobile */
|
||||
@media (max-width: 767px) {
|
||||
.progress-mobile {
|
||||
height: 3px;
|
||||
cursor: pointer;
|
||||
-webkit-appearance: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.progress-mobile::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--primary));
|
||||
cursor: pointer;
|
||||
margin-top: -6px;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
---break---
|
||||
*/
|
||||
*/
|
||||
|
||||
/* Mobile Bottom Navigation Styles */
|
||||
.pb-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0.5rem);
|
||||
}
|
||||
|
||||
.mobile-safe-bottom {
|
||||
margin-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
|
||||
.touch-manipulation {
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.mobile-audio-above-nav {
|
||||
bottom: calc(4rem + env(safe-area-inset-bottom, 0));
|
||||
}
|
||||
@@ -26,6 +26,35 @@ export const metadata = {
|
||||
'max-snippet': -1,
|
||||
},
|
||||
},
|
||||
viewport: {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'black-translucent',
|
||||
title: isDev && shortCommit ? `mice (dev: ${shortCommit})` : 'mice',
|
||||
},
|
||||
formatDetection: {
|
||||
telephone: false,
|
||||
},
|
||||
other: {
|
||||
'apple-mobile-web-app-capable': 'yes',
|
||||
'apple-mobile-web-app-status-bar-style': 'black-translucent',
|
||||
'format-detection': 'telephone=no',
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.ico', sizes: '48x48' },
|
||||
{ url: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ url: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
],
|
||||
apple: [
|
||||
{ url: '/apple-touch-icon-precomposed.png', sizes: '180x180', type: 'image/png' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const geistSans = localFont({
|
||||
|
||||
244
app/library/page.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Music, Users, Disc, ListMusic, Heart, Play } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { getNavidromeAPI } from '@/lib/navidrome';
|
||||
import NavidromeAPI from '@/lib/navidrome';
|
||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
|
||||
interface Album {
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
artistId?: string;
|
||||
coverArt?: string;
|
||||
year?: number;
|
||||
songCount: number;
|
||||
}
|
||||
|
||||
interface LibraryStats {
|
||||
albums: number;
|
||||
artists: number;
|
||||
songs: number;
|
||||
playlists: number;
|
||||
}
|
||||
|
||||
export default function LibraryPage() {
|
||||
const [recentAlbums, setRecentAlbums] = useState<Album[]>([]);
|
||||
const [stats, setStats] = useState<LibraryStats>({ albums: 0, artists: 0, songs: 0, playlists: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [api, setApi] = useState<NavidromeAPI | null>(null);
|
||||
const { playAlbum } = useAudioPlayer();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
useEffect(() => {
|
||||
const loadLibraryData = async () => {
|
||||
try {
|
||||
const navidromeApi = getNavidromeAPI();
|
||||
if (!navidromeApi) {
|
||||
console.error('Navidrome API not available');
|
||||
return;
|
||||
}
|
||||
setApi(navidromeApi);
|
||||
|
||||
// Load recent albums
|
||||
const albumsData = await navidromeApi.getAlbums('newest', 4, 0);
|
||||
setRecentAlbums(albumsData || []);
|
||||
|
||||
// Load library stats
|
||||
const [allAlbums, allArtists, allPlaylists] = await Promise.all([
|
||||
navidromeApi.getAlbums('alphabeticalByName', 1, 0), // Just to get count
|
||||
navidromeApi.getArtists(),
|
||||
navidromeApi.getPlaylists()
|
||||
]);
|
||||
|
||||
setStats({
|
||||
albums: allAlbums?.length || 0,
|
||||
artists: allArtists?.length || 0,
|
||||
songs: 0, // We don't have a direct method for this
|
||||
playlists: allPlaylists?.length || 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load library data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadLibraryData();
|
||||
}, []);
|
||||
|
||||
const handlePlayAlbum = async (album: Album) => {
|
||||
try {
|
||||
await playAlbum(album.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to play album:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const libraryLinks = [
|
||||
{
|
||||
href: '/library/albums',
|
||||
label: 'Albums',
|
||||
icon: Disc,
|
||||
description: 'Browse all albums',
|
||||
count: stats.albums
|
||||
},
|
||||
{
|
||||
href: '/library/artists',
|
||||
label: 'Artists',
|
||||
icon: Users,
|
||||
description: 'Discover artists',
|
||||
count: stats.artists
|
||||
},
|
||||
{
|
||||
href: '/library/songs',
|
||||
label: 'Songs',
|
||||
icon: Music,
|
||||
description: 'All your music',
|
||||
count: stats.songs
|
||||
},
|
||||
{
|
||||
href: '/library/playlists',
|
||||
label: 'Playlists',
|
||||
icon: ListMusic,
|
||||
description: 'Your playlists',
|
||||
count: stats.playlists
|
||||
},
|
||||
{
|
||||
href: '/favorites',
|
||||
label: 'Favorites',
|
||||
icon: Heart,
|
||||
description: 'Starred music',
|
||||
count: 0
|
||||
}
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-4 pb-20 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">Your Library</h1>
|
||||
|
||||
{/* Loading skeleton for library links */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">Browse</h2>
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse">
|
||||
<div className="bg-muted rounded-lg h-16"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading skeleton for recent albums */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">Recently Added</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse">
|
||||
<div className="bg-muted rounded-lg aspect-square mb-2"></div>
|
||||
<div className="bg-muted h-4 rounded mb-1"></div>
|
||||
<div className="bg-muted h-3 rounded w-3/4"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 pb-20 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">Your Library</h1>
|
||||
|
||||
{/* Library Navigation - Always at top */}
|
||||
<div>
|
||||
{/* <h2 className="text-lg font-semibold mb-3">Browse</h2> */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{libraryLinks.map((link) => {
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<Link key={link.href} href={link.href}>
|
||||
<Card className="hover:bg-muted/50 transition-colors cursor-pointer">
|
||||
<CardContent className="p-2">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Icon className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">{link.label}</h3>
|
||||
<p className="text-sm text-muted-foreground">{link.description}</p>
|
||||
</div>
|
||||
{link.count > 0 && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{link.count}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recently Added Albums - At bottom on mobile, after Browse on desktop */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">Recently Added</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{recentAlbums.map((album) => (
|
||||
<Card key={album.id} className="group cursor-pointer hover:bg-muted/50 transition-colors">
|
||||
<CardContent className="p-3">
|
||||
<Link href={`/album/${album.id}`}>
|
||||
<div className="relative aspect-square mb-2">
|
||||
<Image
|
||||
src={album.coverArt && api ? api.getCoverArtUrl(album.coverArt, 300) : '/default-user.jpg'}
|
||||
alt={album.name}
|
||||
width={600}
|
||||
height={600}
|
||||
className="object-cover rounded-lg"
|
||||
sizes="(max-width: 768px) 50vw, 200px"
|
||||
/>
|
||||
{!isMobile && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handlePlayAlbum(album);
|
||||
}}
|
||||
className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center"
|
||||
>
|
||||
<Play className="w-8 h-8 text-white fill-white" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-medium text-sm truncate hover:underline">{album.name}</h3>
|
||||
<Link
|
||||
href={`/artist/${album.artistId || album.artist}`}
|
||||
className="text-xs text-muted-foreground truncate hover:underline block"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{album.artist}
|
||||
</Link>
|
||||
{/* {album.year && (
|
||||
<p className="text-xs text-muted-foreground">{album.year}</p>
|
||||
)} */}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,7 @@ const PlaylistsPage: React.FC = () => {
|
||||
<ScrollArea>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-4"> {playlists.map((playlist) => {
|
||||
const playlistCoverUrl = playlist.coverArt && api
|
||||
? api.getCoverArtUrl(playlist.coverArt, 200)
|
||||
? api.getCoverArtUrl(playlist.coverArt, 600)
|
||||
: '/default-user.jpg';
|
||||
|
||||
return (
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function SongsPage() {
|
||||
|
||||
setFilteredSongs(filtered);
|
||||
}, [songs, searchQuery, sortBy, sortDirection]);
|
||||
const handlePlaySong = (song: Song) => {
|
||||
const handlePlayClick = (song: Song) => {
|
||||
if (!api) {
|
||||
console.error('Navidrome API not available');
|
||||
return;
|
||||
@@ -114,7 +114,7 @@ export default function SongsPage() {
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId,
|
||||
starred: !!song.starred
|
||||
@@ -135,7 +135,7 @@ export default function SongsPage() {
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId,
|
||||
starred: !!song.starred
|
||||
@@ -222,7 +222,7 @@ export default function SongsPage() {
|
||||
className={`group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors ${
|
||||
isCurrentlyPlaying(song) ? 'bg-accent/50 border-l-4 border-primary' : ''
|
||||
}`}
|
||||
onClick={() => handlePlaySong(song)}
|
||||
onClick={() => handlePlayClick(song)}
|
||||
>
|
||||
{/* Track Number / Play Indicator */}
|
||||
<div className="w-8 text-center text-sm text-muted-foreground mr-3">
|
||||
@@ -240,7 +240,7 @@ export default function SongsPage() {
|
||||
|
||||
{/* Album Art */}
|
||||
<div className="w-12 h-12 mr-4 shrink-0"> <Image
|
||||
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 100) : '/default-user.jpg'}
|
||||
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 48) : '/default-user.jpg'}
|
||||
alt={song.album}
|
||||
width={48}
|
||||
height={48}
|
||||
|
||||
@@ -38,6 +38,25 @@ export default function manifest(): MetadataRoute.Manifest {
|
||||
type: 'image/png',
|
||||
sizes: '512x512',
|
||||
purpose: 'maskable'
|
||||
},
|
||||
// Apple Touch Icons for iOS
|
||||
{
|
||||
src: '/apple-touch-icon.png',
|
||||
type: 'image/png',
|
||||
sizes: '180x180',
|
||||
purpose: 'any'
|
||||
},
|
||||
{
|
||||
src: '/icon-192.png',
|
||||
type: 'image/png',
|
||||
sizes: '152x152',
|
||||
purpose: 'any'
|
||||
},
|
||||
{
|
||||
src: '/icon-192.png',
|
||||
type: 'image/png',
|
||||
sizes: '120x120',
|
||||
purpose: 'any'
|
||||
}
|
||||
],
|
||||
screenshots: [
|
||||
|
||||
@@ -12,6 +12,8 @@ import { useSearchParams } from 'next/navigation';
|
||||
import { useAudioPlayer } from './components/AudioPlayerContext';
|
||||
import { SongRecommendations } from './components/SongRecommendations';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { UserProfile } from './components/UserProfile';
|
||||
|
||||
type TimeOfDay = 'morning' | 'afternoon' | 'evening';
|
||||
|
||||
@@ -24,6 +26,7 @@ function MusicPageContent() {
|
||||
const [favoriteAlbums, setFavoriteAlbums] = useState<Album[]>([]);
|
||||
const [favoritesLoading, setFavoritesLoading] = useState(true);
|
||||
const [shortcutProcessed, setShortcutProcessed] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
useEffect(() => {
|
||||
if (albums.length > 0) {
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function PlaylistPage() {
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId,
|
||||
starred: !!song.starred
|
||||
@@ -77,7 +77,7 @@ export default function PlaylistPage() {
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId,
|
||||
starred: !!song.starred
|
||||
@@ -98,7 +98,7 @@ export default function PlaylistPage() {
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
duration: song.duration,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId,
|
||||
starred: !!song.starred
|
||||
@@ -209,7 +209,7 @@ export default function PlaylistPage() {
|
||||
|
||||
{/* Album Art */}
|
||||
<div className="w-12 h-12 mr-4 shrink-0"> <Image
|
||||
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 100) : '/default-user.jpg'}
|
||||
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 48) : '/default-user.jpg'}
|
||||
alt={song.album}
|
||||
width={48}
|
||||
height={48}
|
||||
|
||||
@@ -353,7 +353,7 @@ const SettingsPage = () => {
|
||||
style={{ columnFill: 'balance' }}>
|
||||
|
||||
{!hasEnvConfig && (
|
||||
<Card className="mb-6 break-inside-avoid">
|
||||
<Card className="mb-6 break-inside-avoid py-5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FaServer className="w-5 h-5" />
|
||||
@@ -442,7 +442,7 @@ const SettingsPage = () => {
|
||||
)}
|
||||
|
||||
{hasEnvConfig && (
|
||||
<Card className="mb-6 break-inside-avoid">
|
||||
<Card className="mb-6 break-inside-avoid py-5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FaServer className="w-5 h-5" />
|
||||
@@ -469,7 +469,7 @@ const SettingsPage = () => {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="mb-6 break-inside-avoid">
|
||||
<Card className="mb-6 break-inside-avoid py-5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FaLastfm className="w-5 h-5" />
|
||||
@@ -547,7 +547,7 @@ const SettingsPage = () => {
|
||||
</CardContent>
|
||||
</Card> */}
|
||||
|
||||
<Card className="mb-6 break-inside-avoid">
|
||||
<Card className="mb-6 break-inside-avoid py-5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Settings className="w-5 h-5" />
|
||||
@@ -602,7 +602,7 @@ const SettingsPage = () => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-6 break-inside-avoid">
|
||||
{/* <Card className="mb-6 break-inside-avoid py-5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FaLastfm className="w-5 h-5" />
|
||||
@@ -695,7 +695,7 @@ const SettingsPage = () => {
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Card> */}
|
||||
|
||||
{/* Sidebar Customization */}
|
||||
<div className="break-inside-avoid mb-6">
|
||||
@@ -712,7 +712,7 @@ const SettingsPage = () => {
|
||||
<CacheManagement />
|
||||
</div>
|
||||
|
||||
<Card className="mb-6 break-inside-avoid">
|
||||
<Card className="mb-6 break-inside-avoid py-5">
|
||||
<CardHeader>
|
||||
<CardTitle>Appearance</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -761,7 +761,7 @@ const SettingsPage = () => {
|
||||
</Card>
|
||||
|
||||
{/* Theme Preview */}
|
||||
<Card className="mb-6 break-inside-avoid">
|
||||
<Card className="mb-6 break-inside-avoid py-5">
|
||||
<CardHeader>
|
||||
<CardTitle>Preview</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -789,6 +789,47 @@ const SettingsPage = () => {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Debug Section - Development Only */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<Card className="mb-6 break-inside-avoid py-5 border-orange-200 bg-orange-50/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-orange-700">
|
||||
<Settings className="w-5 h-5" />
|
||||
Debug Tools
|
||||
</CardTitle>
|
||||
<CardDescription className="text-orange-600">
|
||||
Development-only debugging utilities
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
onClick={() => {
|
||||
// Save Navidrome config before clearing
|
||||
const navidromeConfig = localStorage.getItem('navidrome-config');
|
||||
|
||||
// Clear all localStorage
|
||||
localStorage.clear();
|
||||
|
||||
// Restore Navidrome config
|
||||
if (navidromeConfig) {
|
||||
localStorage.setItem('navidrome-config', navidromeConfig);
|
||||
}
|
||||
|
||||
// Reload page to reset state
|
||||
window.location.reload();
|
||||
}}
|
||||
variant="outline"
|
||||
className="w-full bg-orange-100 border-orange-300 text-orange-700 hover:bg-orange-200"
|
||||
>
|
||||
Clear All Data (Keep Navidrome Config)
|
||||
</Button>
|
||||
<p className="text-xs text-orange-600 mt-2">
|
||||
This will clear all localStorage data except your Navidrome server configuration, then reload the page.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-0 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -3,18 +3,18 @@ version: '3.8'
|
||||
services:
|
||||
mice:
|
||||
container_name: mice-public
|
||||
image: sillyangel/mice:latest
|
||||
image: sillyangel/mice:dev-latest
|
||||
ports:
|
||||
- "40625:40625"
|
||||
environment:
|
||||
# Navidrome Server Configuration
|
||||
- NAVIDROME_URL=https://navi.sillyangel.dev
|
||||
- NAVIDROME_USERNAME=kryptonite
|
||||
- NAVIDROME_PASSWORD=kryptonite
|
||||
# - NAVIDROME_URL=http://navidrome:4533
|
||||
# - NAVIDROME_USERNAME=user
|
||||
# - NAVIDROME_PASSWORD=password
|
||||
|
||||
# PostHog Analytics
|
||||
- POSTHOG_KEY=phc_Sa39J7754MwaHrPxYiWnWETVSD3g1cU4nOplMGczRE9
|
||||
- POSTHOG_HOST=https://us.i.posthog.com
|
||||
# # PostHog Analytics
|
||||
# - POSTHOG_KEY=phc_Sa39J7754MwaHrPxYiWnWETVSD3g1cU4nOplMGczRE9
|
||||
# - POSTHOG_HOST=https://us.i.posthog.com
|
||||
|
||||
# Application Port
|
||||
- PORT=40625
|
||||
@@ -27,3 +27,24 @@ services:
|
||||
start_period: 40s
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
navidrome:
|
||||
container_name: navidrome
|
||||
image: deluan/navidrome:latest
|
||||
ports:
|
||||
- "4533:4533"
|
||||
environment:
|
||||
- ND_SCANINTERVAL=1m
|
||||
- ND_LOGLEVEL=info
|
||||
- ND_SESSIONTIMEOUT=24h
|
||||
- ND_PORT=4533
|
||||
# - ND_BASEURL=/navidrome
|
||||
# - ND_MUSICFOLDER=/music
|
||||
volumes:
|
||||
- navidrome_data:/data
|
||||
- navidrome_music:/music
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
navidrome_data:
|
||||
navidrome_music:
|
||||
|
||||
@@ -2,9 +2,10 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
mice:
|
||||
image: sillyangel/mice:latest
|
||||
container_name: mice-public
|
||||
image: sillyangel/mice:dev-latest
|
||||
ports:
|
||||
- "${HOST_PORT:-3000}:${PORT:-3000}"
|
||||
- "40625:40625"
|
||||
environment:
|
||||
# Navidrome Server Configuration
|
||||
# These will be injected at runtime using the entrypoint script
|
||||
@@ -13,18 +14,35 @@ services:
|
||||
- NEXT_PUBLIC_NAVIDROME_PASSWORD=${NAVIDROME_PASSWORD:-}
|
||||
|
||||
# PostHog Analytics (optional)
|
||||
- NEXT_PUBLIC_POSTHOG_KEY=${POSTHOG_KEY:-}
|
||||
- NEXT_PUBLIC_POSTHOG_HOST=${POSTHOG_HOST:-}
|
||||
|
||||
# - NEXT_PUBLIC_POSTHOG_KEY=phc_Sa39J7754MwaHrPxYiWnWETVSD3g1cU4nOplMGczRE9
|
||||
# - NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
|
||||
|
||||
# Application Port
|
||||
- PORT=${PORT:-3000}
|
||||
- PORT=40625
|
||||
|
||||
# Optional: Add a health check
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:${PORT:-3000}"]
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:40625"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
navidrome:
|
||||
container_name: navidrome
|
||||
image: deluan/navidrome:latest
|
||||
ports:
|
||||
- "4533:4533"
|
||||
environment:
|
||||
- ND_SCANINTERVAL=1m
|
||||
- ND_LOGLEVEL=info
|
||||
- ND_SESSIONTIMEOUT=24h
|
||||
- ND_PORT=4533
|
||||
# - ND_BASEURL=/navidrome
|
||||
# - ND_MUSICFOLDER=/music
|
||||
volumes:
|
||||
- navidrome_data:/data
|
||||
- navidrome_music:/music
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
@@ -15,6 +15,3 @@ printenv | grep NEXT_PUBLIC_ | while read -r line ; do
|
||||
done
|
||||
|
||||
echo "✅ Environment variable replacement complete"
|
||||
|
||||
# Execute the container's main process (CMD in Dockerfile)
|
||||
exec "$@"
|
||||
|
||||
96
hooks/use-responsive-image-size.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface UseResponsiveImageSizeOptions {
|
||||
/** Minimum size threshold */
|
||||
minSize?: number;
|
||||
/** Maximum size threshold */
|
||||
maxSize?: number;
|
||||
/** Multiplier for high DPI displays */
|
||||
dpiMultiplier?: number;
|
||||
/** Available size tiers from Navidrome */
|
||||
availableSizes?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to calculate optimal image size based on container dimensions
|
||||
*/
|
||||
export function useResponsiveImageSize(options: UseResponsiveImageSizeOptions = {}) {
|
||||
const {
|
||||
minSize = 60,
|
||||
maxSize = 1200,
|
||||
dpiMultiplier = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1,
|
||||
availableSizes = [60, 120, 240, 400, 600, 1200] // Clean divisions of 1200
|
||||
} = options;
|
||||
|
||||
const containerRef = useRef<HTMLElement>(null);
|
||||
const [imageSize, setImageSize] = useState<number>(300); // Default fallback
|
||||
|
||||
useEffect(() => {
|
||||
const calculateOptimalSize = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const element = containerRef.current;
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
// Use the larger dimension (width or height) as base
|
||||
const displaySize = Math.max(rect.width, rect.height);
|
||||
|
||||
// Account for device pixel ratio for crisp images on high DPI displays
|
||||
const targetSize = Math.round(displaySize * dpiMultiplier);
|
||||
|
||||
// Clamp to min/max bounds
|
||||
const clampedSize = Math.max(minSize, Math.min(maxSize, targetSize));
|
||||
|
||||
// Find the next larger available size to ensure quality
|
||||
const optimalSize = availableSizes.find(size => size >= clampedSize) || availableSizes[availableSizes.length - 1];
|
||||
|
||||
setImageSize(optimalSize);
|
||||
};
|
||||
|
||||
// Calculate initial size
|
||||
calculateOptimalSize();
|
||||
|
||||
// Recalculate on resize
|
||||
const resizeObserver = new ResizeObserver(calculateOptimalSize);
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [minSize, maxSize, dpiMultiplier, availableSizes]);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
imageSize,
|
||||
/** Get size for a specific display dimension */
|
||||
getSizeForDimension: (dimension: number) => {
|
||||
const targetSize = Math.round(dimension * dpiMultiplier);
|
||||
const clampedSize = Math.max(minSize, Math.min(maxSize, targetSize));
|
||||
return availableSizes.find(size => size >= clampedSize) || availableSizes[availableSizes.length - 1];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple function to get optimal image size for known dimensions
|
||||
*/
|
||||
export function getOptimalImageSize(
|
||||
displayWidth: number,
|
||||
displayHeight: number,
|
||||
options: Omit<UseResponsiveImageSizeOptions, 'availableSizes'> & { availableSizes?: number[] } = {}
|
||||
): number {
|
||||
const {
|
||||
minSize = 60,
|
||||
maxSize = 1200,
|
||||
dpiMultiplier = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1,
|
||||
availableSizes = [60, 120, 240, 400, 600, 1200] // Clean divisions of 1200
|
||||
} = options;
|
||||
|
||||
const displaySize = Math.max(displayWidth, displayHeight);
|
||||
const targetSize = Math.round(displaySize * dpiMultiplier);
|
||||
const clampedSize = Math.max(minSize, Math.min(maxSize, targetSize));
|
||||
|
||||
return availableSizes.find(size => size >= clampedSize) || availableSizes[availableSizes.length - 1];
|
||||
}
|
||||
38
lib/gravatar.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Generate a Gravatar URL from an email address
|
||||
* @param email - The email address
|
||||
* @param size - The size of the image (default: 80)
|
||||
* @param defaultImage - Default image type if no Gravatar found (default: 'identicon')
|
||||
* @returns The Gravatar URL
|
||||
*/
|
||||
export function getGravatarUrl(
|
||||
email: string,
|
||||
size: number = 80,
|
||||
defaultImage: string = 'identicon'
|
||||
): string {
|
||||
// Normalize email: trim whitespace and convert to lowercase
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
|
||||
// Generate MD5 hash of the email
|
||||
const hash = crypto.createHash('md5').update(normalizedEmail).digest('hex');
|
||||
|
||||
// Construct the Gravatar URL
|
||||
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=${defaultImage}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a Gravatar URL with retina support (2x size)
|
||||
* @param email - The email address
|
||||
* @param size - The base size of the image
|
||||
* @param defaultImage - Default image type if no Gravatar found
|
||||
* @returns The Gravatar URL at 2x resolution
|
||||
*/
|
||||
export function getGravatarUrlRetina(
|
||||
email: string,
|
||||
size: number = 80,
|
||||
defaultImage: string = 'identicon'
|
||||
): string {
|
||||
return getGravatarUrl(email, size * 2, defaultImage);
|
||||
}
|
||||
125
lib/image-utils.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Utility functions for calculating optimal image sizes for different contexts
|
||||
*/
|
||||
|
||||
export interface ImageSizeContext {
|
||||
/** The display width in CSS pixels */
|
||||
displayWidth: number;
|
||||
/** The display height in CSS pixels */
|
||||
displayHeight: number;
|
||||
/** Device pixel ratio for high-DPI displays */
|
||||
devicePixelRatio?: number;
|
||||
/** Additional scaling factor (e.g., for hover effects) */
|
||||
scaleFactor?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the optimal image size for the given context
|
||||
* Takes into account device pixel ratio and potential scaling effects
|
||||
*/
|
||||
export function calculateOptimalImageSize(context: ImageSizeContext): number {
|
||||
const { displayWidth, displayHeight, devicePixelRatio = 1, scaleFactor = 1.1 } = context;
|
||||
|
||||
// Use the larger dimension to ensure we cover the entire display area
|
||||
const baseDimension = Math.max(displayWidth, displayHeight);
|
||||
|
||||
// Account for device pixel ratio and potential scaling
|
||||
const optimalSize = Math.ceil(baseDimension * devicePixelRatio * scaleFactor);
|
||||
|
||||
// Cap at reasonable maximum to avoid excessive bandwidth usage
|
||||
return Math.min(optimalSize, 1200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get optimal image size for common component contexts
|
||||
* All sizes are clean divisions of 1200 for optimal scaling
|
||||
*/
|
||||
export const ImageSizes = {
|
||||
// Small thumbnails in lists - 1200/20 = 60, rounded to 64 for better display
|
||||
THUMBNAIL: 60,
|
||||
|
||||
// Small album covers in compact views - 1200/10 = 120
|
||||
SMALL_ALBUM: 120,
|
||||
|
||||
// Medium album covers in grid views - 1200/5 = 240
|
||||
MEDIUM_ALBUM: 240,
|
||||
|
||||
// Large album covers in detail views - 1200/3 = 400
|
||||
LARGE_ALBUM: 400,
|
||||
|
||||
// Extra large for full-screen displays - 1200/2 = 600
|
||||
XLARGE_ALBUM: 600,
|
||||
|
||||
// Full resolution - 1200/1 = 1200
|
||||
FULL_ALBUM: 1200,
|
||||
|
||||
// Artist images
|
||||
ARTIST_SMALL: 120, // 1200/10
|
||||
ARTIST_MEDIUM: 240, // 1200/5
|
||||
ARTIST_LARGE: 400, // 1200/3
|
||||
|
||||
// Player images
|
||||
PLAYER_MINI: 60, // 1200/20
|
||||
PLAYER_COMPACT: 120, // 1200/10
|
||||
PLAYER_FULL: 400, // 1200/3
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get responsive image size based on container and viewport
|
||||
*/
|
||||
export function getResponsiveImageSize(
|
||||
containerWidth: number,
|
||||
viewportWidth: number = typeof window !== 'undefined' ? window?.innerWidth || 1920 : 1920,
|
||||
devicePixelRatio: number = typeof window !== 'undefined' ? window?.devicePixelRatio || 1 : 1
|
||||
): number {
|
||||
let targetSize: number;
|
||||
|
||||
// Determine base size based on container and viewport
|
||||
// All sizes are clean divisions of 1200
|
||||
if (containerWidth <= 60) {
|
||||
targetSize = ImageSizes.THUMBNAIL; // 60px
|
||||
} else if (containerWidth <= 120) {
|
||||
targetSize = ImageSizes.SMALL_ALBUM; // 120px
|
||||
} else if (containerWidth <= 240 || viewportWidth <= 768) {
|
||||
targetSize = ImageSizes.MEDIUM_ALBUM; // 240px
|
||||
} else if (containerWidth <= 400 || viewportWidth <= 1024) {
|
||||
targetSize = ImageSizes.LARGE_ALBUM; // 400px
|
||||
} else if (containerWidth <= 600 || viewportWidth <= 1440) {
|
||||
targetSize = ImageSizes.XLARGE_ALBUM; // 600px
|
||||
} else {
|
||||
targetSize = ImageSizes.FULL_ALBUM; // 1200px
|
||||
}
|
||||
|
||||
// Apply device pixel ratio but ensure we stay within clean divisions of 1200
|
||||
const scaledSize = Math.ceil(targetSize * devicePixelRatio);
|
||||
|
||||
// Round to nearest clean division of 1200
|
||||
const divisions = [60, 120, 240, 400, 600, 1200];
|
||||
return divisions.find(size => size >= scaledSize) || 1200;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get optimal image size for a container
|
||||
* Returns clean divisions of 1200 for optimal scaling
|
||||
*/
|
||||
export function useOptimalImageSize(
|
||||
width: number,
|
||||
height: number = width,
|
||||
scaleFactor: number = 1.1
|
||||
): number {
|
||||
if (typeof window === 'undefined') {
|
||||
// SSR fallback - return appropriate size based on dimensions
|
||||
return getResponsiveImageSize(width, 1920, 1);
|
||||
}
|
||||
|
||||
const optimalSize = calculateOptimalImageSize({
|
||||
displayWidth: width,
|
||||
displayHeight: height,
|
||||
devicePixelRatio: window.devicePixelRatio || 1,
|
||||
scaleFactor,
|
||||
});
|
||||
|
||||
// Round to nearest clean division of 1200
|
||||
const divisions = [60, 120, 240, 400, 600, 1200];
|
||||
return divisions.find(size => size >= optimalSize) || 1200;
|
||||
}
|
||||
@@ -110,6 +110,26 @@ export interface ArtistInfo {
|
||||
similarArtist?: Artist[];
|
||||
}
|
||||
|
||||
export interface User {
|
||||
username: string;
|
||||
email?: string;
|
||||
scrobblingEnabled: boolean;
|
||||
maxBitRate?: number;
|
||||
adminRole: boolean;
|
||||
settingsRole: boolean;
|
||||
downloadRole: boolean;
|
||||
uploadRole: boolean;
|
||||
playlistRole: boolean;
|
||||
coverArtRole: boolean;
|
||||
commentRole: boolean;
|
||||
podcastRole: boolean;
|
||||
streamRole: boolean;
|
||||
jukeboxRole: boolean;
|
||||
shareRole: boolean;
|
||||
videoConversionRole: boolean;
|
||||
avatarLastChanged?: string;
|
||||
}
|
||||
|
||||
class NavidromeAPI {
|
||||
private config: NavidromeConfig;
|
||||
private clientName = 'miceclient';
|
||||
@@ -171,6 +191,12 @@ class NavidromeAPI {
|
||||
}
|
||||
}
|
||||
|
||||
async getUserInfo(): Promise<User> {
|
||||
const response = await this.makeRequest('getUser', { username: this.config.username });
|
||||
const userData = response.user as User;
|
||||
return userData;
|
||||
}
|
||||
|
||||
async getArtists(): Promise<Artist[]> {
|
||||
const response = await this.makeRequest('getArtists');
|
||||
const artists: Artist[] = [];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
qualities: [50, 75, 100],
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
|
||||
35
package.json
@@ -13,14 +13,14 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@hookform/resolvers": "^5.2.0",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-context-menu": "^2.2.2",
|
||||
"@radix-ui/react-context-menu": "^2.2.15",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-hover-card": "^1.1.14",
|
||||
@@ -29,11 +29,11 @@
|
||||
"@radix-ui/react-menubar": "^1.1.15",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.1",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.1",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.3.5",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
@@ -43,7 +43,7 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@types/react-beautiful-dnd": "^13.1.8",
|
||||
"axios": "^1.8.2",
|
||||
"axios": "^1.11.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -52,14 +52,14 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "15.3.4",
|
||||
"next": "15.4.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"posthog-js": "^1.255.0",
|
||||
"posthog-node": "^5.1.1",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "^9.7.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "^7.53.2",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"react-icons": "^5.3.0",
|
||||
"react-resizable-panels": "^3.0.3",
|
||||
"recharts": "^3.0.2",
|
||||
@@ -67,23 +67,34 @@
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.25.70"
|
||||
"zod": "^4.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@types/node": "^24.0.10",
|
||||
"@types/node": "^24.1.0",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"chalk": "^5.3.0",
|
||||
"eslint": "^9.30",
|
||||
"eslint-config-next": "15.3.5",
|
||||
"eslint": "^9.31",
|
||||
"eslint": "^9.32",
|
||||
"eslint-config-next": "15.4.4",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"packageManager": "pnpm@10.12.4",
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"overrides": {
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"sharp",
|
||||
"unrs-resolver"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
1358
pnpm-lock.yaml
generated
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
onlyBuiltDependencies:
|
||||
- unrs-resolver
|
||||
|
Before Width: | Height: | Size: 869 KiB After Width: | Height: | Size: 105 KiB |
BIN
public/apple-touch-icon-precomposed.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 4.0 MiB After Width: | Height: | Size: 481 KiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 397 KiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 339 KiB |