Merge pull request #31 from sillyangel/mobile-support
Some checks failed
Release Docker Image / push_to_registry (push) Failing after 7s
YAY
@@ -1 +1 @@
|
|||||||
NEXT_PUBLIC_COMMIT_SHA=35febc5
|
NEXT_PUBLIC_COMMIT_SHA=0c32c05
|
||||||
|
|||||||
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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -10,9 +10,9 @@ import Link from 'next/link';
|
|||||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext'
|
import { useAudioPlayer } from '@/app/components/AudioPlayerContext'
|
||||||
import Loading from "@/app/components/loading";
|
import Loading from "@/app/components/loading";
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
||||||
import { getNavidromeAPI } from '@/lib/navidrome';
|
import { getNavidromeAPI } from '@/lib/navidrome';
|
||||||
import { useFavoriteAlbums } from '@/hooks/use-favorite-albums';
|
import { useFavoriteAlbums } from '@/hooks/use-favorite-albums';
|
||||||
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
|
|
||||||
export default function AlbumPage() {
|
export default function AlbumPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -24,6 +24,7 @@ export default function AlbumPage() {
|
|||||||
const { getAlbum, starItem, unstarItem } = useNavidrome();
|
const { getAlbum, starItem, unstarItem } = useNavidrome();
|
||||||
const { playTrack, addAlbumToQueue, playAlbum, playAlbumFromTrack, currentTrack } = useAudioPlayer();
|
const { playTrack, addAlbumToQueue, playAlbum, playAlbumFromTrack, currentTrack } = useAudioPlayer();
|
||||||
const { isFavoriteAlbum, toggleFavoriteAlbum } = useFavoriteAlbums();
|
const { isFavoriteAlbum, toggleFavoriteAlbum } = useFavoriteAlbums();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const api = getNavidromeAPI();
|
const api = getNavidromeAPI();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -119,110 +120,157 @@ export default function AlbumPage() {
|
|||||||
const seconds = duration % 60;
|
const seconds = duration % 60;
|
||||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
// Get cover art URL with proper fallback
|
|
||||||
const coverArtUrl = album.coverArt && api
|
// Dynamic cover art URLs based on image size
|
||||||
? api.getCoverArtUrl(album.coverArt, 300)
|
const getMobileCoverArtUrl = () => {
|
||||||
: '/default-user.jpg';
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="h-full px-4 py-6 lg:px-8">
|
<div className="h-full px-4 py-6 lg:px-8">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-start gap-6">
|
{isMobile ? (
|
||||||
<Image
|
/* Mobile Layout */
|
||||||
src={coverArtUrl}
|
<div className="space-y-6">
|
||||||
alt={album.name}
|
{/* Album Cover - Centered */}
|
||||||
width={300}
|
<div className="flex justify-center">
|
||||||
height={300}
|
<Image
|
||||||
className="rounded-md"
|
src={getMobileCoverArtUrl()}
|
||||||
/>
|
alt={album.name}
|
||||||
<div className="space-y-2">
|
width={280}
|
||||||
<div className="flex items-center space-x-4">
|
height={280}
|
||||||
<p className="text-3xl font-semibold tracking-tight">{album.name}</p>
|
className="rounded-md shadow-lg"
|
||||||
<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>
|
</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>
|
</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>
|
||||||
</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">
|
<div className="space-y-4">
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<ScrollArea className="h-[calc(100vh-500px)]">
|
{tracklist.length === 0 ? (
|
||||||
{tracklist.length === 0 ? (
|
<div className="text-center py-12">
|
||||||
<div className="text-center py-12">
|
<p className="text-muted-foreground">No tracks available.</p>
|
||||||
<p className="text-muted-foreground">No tracks available.</p>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<div className="space-y-1 pb-32">
|
||||||
<div className="space-y-1">
|
{tracklist.map((song, index) => (
|
||||||
{tracklist.map((song, index) => (
|
<div
|
||||||
<div
|
key={song.id}
|
||||||
key={song.id}
|
className={`group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors`}
|
||||||
className={`group flex items-center p-3 rounded-lg hover:bg-accent/50 cursor-pointer transition-colors`}
|
onClick={() => handlePlayClick(song)}
|
||||||
onClick={() => handlePlayClick(song)}
|
>
|
||||||
>
|
{/* Track Number / Play Indicator */}
|
||||||
{/* Track Number / Play Indicator */}
|
<div className="w-8 text-center text-sm text-muted-foreground mr-3">
|
||||||
<div className="w-8 text-center text-sm text-muted-foreground mr-3">
|
<>
|
||||||
<>
|
<span className="group-hover:hidden">{song.track || index + 1}</span>
|
||||||
<span className="group-hover:hidden">{song.track || index + 1}</span>
|
<Play className="w-4 h-4 mx-auto hidden group-hover:block" />
|
||||||
<Play className="w-4 h-4 mx-auto hidden group-hover:block" />
|
</>
|
||||||
</>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Song Info */}
|
{/* Song Info */}
|
||||||
<div className="flex-1 min-w-0 mr-4">
|
<div className="flex-1 min-w-0 mr-4">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<p className={`font-semibold truncate ${
|
<p className={`font-semibold truncate ${
|
||||||
isCurrentlyPlaying(song) ? 'text-primary' : ''
|
isCurrentlyPlaying(song) ? 'text-primary' : ''
|
||||||
}`}>
|
}`}>
|
||||||
{song.title}
|
{song.title}
|
||||||
</p>
|
</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>
|
||||||
<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>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
{/* Duration */}
|
||||||
)}
|
<div className="flex items-center text-sm text-muted-foreground mr-4">
|
||||||
</ScrollArea>
|
{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
|||||||
import Loading from '@/app/components/loading';
|
import Loading from '@/app/components/loading';
|
||||||
import { getNavidromeAPI } from '@/lib/navidrome';
|
import { getNavidromeAPI } from '@/lib/navidrome';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
|
|
||||||
export default function ArtistPage() {
|
export default function ArtistPage() {
|
||||||
const { artist: artistId } = useParams();
|
const { artist: artistId } = useParams();
|
||||||
@@ -27,6 +28,7 @@ export default function ArtistPage() {
|
|||||||
const { getArtist, starItem, unstarItem } = useNavidrome();
|
const { getArtist, starItem, unstarItem } = useNavidrome();
|
||||||
const { playArtist } = useAudioPlayer();
|
const { playArtist } = useAudioPlayer();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const api = getNavidromeAPI();
|
const api = getNavidromeAPI();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -103,7 +105,7 @@ export default function ArtistPage() {
|
|||||||
}
|
}
|
||||||
// Get artist image URL with proper fallback
|
// Get artist image URL with proper fallback
|
||||||
const artistImageUrl = artist.coverArt && api
|
const artistImageUrl = artist.coverArt && api
|
||||||
? api.getCoverArtUrl(artist.coverArt, 300)
|
? api.getCoverArtUrl(artist.coverArt, 1200)
|
||||||
: '/default-user.jpg';
|
: '/default-user.jpg';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -152,7 +154,7 @@ export default function ArtistPage() {
|
|||||||
<ArtistBio artistName={artist.name} />
|
<ArtistBio artistName={artist.name} />
|
||||||
|
|
||||||
{/* Popular Songs Section */}
|
{/* Popular Songs Section */}
|
||||||
{popularSongs.length > 0 && (
|
{!isMobile && popularSongs.length > 0 && (
|
||||||
<PopularSongs songs={popularSongs} artistName={artist.name} />
|
<PopularSongs songs={popularSongs} artistName={artist.name} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,19 @@ import { Progress } from '@/components/ui/progress';
|
|||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { useLastFmScrobbler } from '@/hooks/use-lastfm-scrobbler';
|
import { useLastFmScrobbler } from '@/hooks/use-lastfm-scrobbler';
|
||||||
import { useStandaloneLastFm } from '@/hooks/use-standalone-lastfm';
|
import { useStandaloneLastFm } from '@/hooks/use-standalone-lastfm';
|
||||||
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
|
|
||||||
export const AudioPlayer: React.FC = () => {
|
export const AudioPlayer: React.FC = () => {
|
||||||
const { currentTrack, playPreviousTrack, addToQueue, playNextTrack, clearQueue, queue, toggleShuffle, shuffle, toggleCurrentTrackStar } = useAudioPlayer();
|
const { currentTrack, playPreviousTrack, addToQueue, playNextTrack, clearQueue, queue, toggleShuffle, shuffle, toggleCurrentTrackStar } = useAudioPlayer();
|
||||||
const router = useRouter();
|
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 audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const preloadAudioRef = useRef<HTMLAudioElement>(null);
|
const preloadAudioRef = useRef<HTMLAudioElement>(null);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
@@ -23,9 +32,36 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
const [isClient, setIsClient] = useState(false);
|
const [isClient, setIsClient] = useState(false);
|
||||||
const [isMinimized, setIsMinimized] = useState(false);
|
const [isMinimized, setIsMinimized] = useState(false);
|
||||||
const [isFullScreen, setIsFullScreen] = useState(false);
|
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||||
|
const [audioInitialized, setAudioInitialized] = useState(false);
|
||||||
const audioCurrent = audioRef.current;
|
const audioCurrent = audioRef.current;
|
||||||
const { toast } = useToast();
|
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)
|
// Last.fm scrobbler integration (Navidrome)
|
||||||
const {
|
const {
|
||||||
onTrackStart: navidromeOnTrackStart,
|
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
|
// Clean up old localStorage entries with track IDs
|
||||||
const keysToRemove: string[] = [];
|
const keysToRemove: string[] = [];
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
@@ -100,7 +219,7 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
keysToRemove.forEach(key => localStorage.removeItem(key));
|
keysToRemove.forEach(key => localStorage.removeItem(key));
|
||||||
}, []);
|
}, [isMobile, audioInitialized, volume]);
|
||||||
|
|
||||||
// Apply volume to audio element when volume changes
|
// Apply volume to audio element when volume changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -129,8 +248,76 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
// Always clear current track time when changing tracks
|
// Always clear current track time when changing tracks
|
||||||
localStorage.removeItem('navidrome-current-track-time');
|
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;
|
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
|
// Notify scrobbler about new track
|
||||||
onTrackStart(currentTrack);
|
onTrackStart(currentTrack);
|
||||||
|
|
||||||
@@ -157,21 +344,31 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
localStorage.removeItem('navidrome-current-track-time');
|
localStorage.removeItem('navidrome-current-track-time');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-play only if the track has the autoPlay flag
|
// Auto-play only if the track has the autoPlay flag and audio is initialized
|
||||||
if (currentTrack.autoPlay) {
|
if (currentTrack.autoPlay && (!isMobile || audioInitialized)) {
|
||||||
audioCurrent.play().then(() => {
|
// 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);
|
setIsPlaying(true);
|
||||||
// Notify scrobbler about play
|
// Notify scrobbler about play
|
||||||
onTrackPlay(currentTrack);
|
onTrackPlay(currentTrack);
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error('Failed to auto-play:', error);
|
console.error('Failed to auto-play:', error);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
|
|
||||||
|
// On iOS, auto-play might fail - that's normal
|
||||||
|
if (isMobile) {
|
||||||
|
console.log('Auto-play failed on mobile - user interaction required');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [currentTrack, onTrackStart, onTrackPlay]);
|
}, [currentTrack, onTrackStart, onTrackPlay, isMobile, audioInitialized]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audioCurrent = audioRef.current;
|
const audioCurrent = audioRef.current;
|
||||||
@@ -245,69 +442,131 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, [playNextTrack, currentTrack, onTrackProgress, onTrackEnd, onTrackPlay, onTrackPause]);
|
}, [playNextTrack, currentTrack, onTrackProgress, onTrackEnd, onTrackPlay, onTrackPause]);
|
||||||
|
|
||||||
// Media Session API integration
|
// Media Session API integration - Enhanced for mobile
|
||||||
useEffect(() => {
|
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
|
try {
|
||||||
navigator.mediaSession.metadata = new MediaMetadata({
|
// Set metadata
|
||||||
title: currentTrack.name,
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
artist: currentTrack.artist,
|
title: currentTrack.name,
|
||||||
album: currentTrack.album,
|
artist: currentTrack.artist,
|
||||||
artwork: currentTrack.coverArt ? [
|
album: currentTrack.album,
|
||||||
{ src: currentTrack.coverArt, sizes: '512x512', type: 'image/jpeg' }
|
artwork: currentTrack.coverArt ? [
|
||||||
] : undefined,
|
{ 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
|
// Set playback state
|
||||||
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
|
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
|
||||||
|
|
||||||
// Set action handlers
|
// Set action handlers with error handling
|
||||||
navigator.mediaSession.setActionHandler('play', () => {
|
navigator.mediaSession.setActionHandler('play', () => {
|
||||||
const audioCurrent = audioRef.current;
|
const audioCurrent = audioRef.current;
|
||||||
if (audioCurrent && currentTrack) {
|
if (audioCurrent && currentTrack) {
|
||||||
audioCurrent.play();
|
audioCurrent.play().then(() => {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
onTrackPlay(currentTrack);
|
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', () => {
|
// Update position state for better scrubbing support
|
||||||
const audioCurrent = audioRef.current;
|
const updatePositionState = () => {
|
||||||
if (audioCurrent && currentTrack) {
|
const audioCurrent = audioRef.current;
|
||||||
audioCurrent.pause();
|
if (audioCurrent && currentTrack && 'setPositionState' in navigator.mediaSession) {
|
||||||
setIsPlaying(false);
|
try {
|
||||||
onTrackPause(audioCurrent.currentTime);
|
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', () => {
|
// Update position state periodically
|
||||||
playPreviousTrack();
|
const positionInterval = setInterval(updatePositionState, 1000);
|
||||||
});
|
|
||||||
|
|
||||||
navigator.mediaSession.setActionHandler('nexttrack', () => {
|
return () => {
|
||||||
playNextTrack();
|
clearInterval(positionInterval);
|
||||||
});
|
if ('mediaSession' in navigator) {
|
||||||
|
navigator.mediaSession.setActionHandler('play', null);
|
||||||
navigator.mediaSession.setActionHandler('seekto', (details) => {
|
navigator.mediaSession.setActionHandler('pause', null);
|
||||||
const audioCurrent = audioRef.current;
|
navigator.mediaSession.setActionHandler('previoustrack', null);
|
||||||
if (audioCurrent && details.seekTime !== undefined) {
|
navigator.mediaSession.setActionHandler('nexttrack', null);
|
||||||
audioCurrent.currentTime = details.seekTime;
|
navigator.mediaSession.setActionHandler('seekto', null);
|
||||||
}
|
try {
|
||||||
});
|
const mediaSession = navigator.mediaSession as MediaSession & {
|
||||||
|
setActionHandler(action: 'togglefavorite', handler: MediaSessionActionHandler | null): void;
|
||||||
return () => {
|
};
|
||||||
if ('mediaSession' in navigator) {
|
mediaSession.setActionHandler('togglefavorite', null);
|
||||||
navigator.mediaSession.setActionHandler('play', null);
|
} catch (error) {
|
||||||
navigator.mediaSession.setActionHandler('pause', null);
|
// togglefavorite might not be supported
|
||||||
navigator.mediaSession.setActionHandler('previoustrack', null);
|
}
|
||||||
navigator.mediaSession.setActionHandler('nexttrack', null);
|
}
|
||||||
navigator.mediaSession.setActionHandler('seekto', null);
|
};
|
||||||
}
|
} catch (error) {
|
||||||
};
|
console.error('MediaSession setup failed:', error);
|
||||||
}, [currentTrack, isPlaying, isClient, playPreviousTrack, playNextTrack, onTrackPlay, onTrackPause]);
|
}
|
||||||
|
}, [currentTrack, isPlaying, isClient, playPreviousTrack, playNextTrack, onTrackPlay, onTrackPause, toggleCurrentTrackStar]);
|
||||||
|
|
||||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||||
|
e.stopPropagation(); // Prevent triggering fullscreen
|
||||||
if (audioCurrent && currentTrack) {
|
if (audioCurrent && currentTrack) {
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
const clickX = e.clientX - rect.left;
|
const clickX = e.clientX - rect.left;
|
||||||
@@ -319,20 +578,135 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const togglePlayPause = () => {
|
const togglePlayPause = async () => {
|
||||||
if (audioCurrent && currentTrack) {
|
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) {
|
if (isPlaying) {
|
||||||
|
console.log('⏸️ Pausing audio');
|
||||||
audioCurrent.pause();
|
audioCurrent.pause();
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
onTrackPause(audioCurrent.currentTime);
|
onTrackPause(audioCurrent.currentTime);
|
||||||
} else {
|
} 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);
|
setIsPlaying(true);
|
||||||
|
setAudioInitialized(true);
|
||||||
onTrackPlay(currentTrack);
|
onTrackPlay(currentTrack);
|
||||||
}).catch((error) => {
|
console.log('✅ Audio play successful');
|
||||||
console.error('Failed to play audio:', error);
|
} catch (error) {
|
||||||
setIsPlaying(false);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mini player (collapsed state)
|
// Mobile compact mini player :3
|
||||||
if (isMinimized) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-4 left-4 z-50">
|
<>
|
||||||
<div
|
<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">
|
||||||
className="bg-background/95 backdrop-blur-xs border rounded-lg shadow-lg cursor-pointer hover:scale-[1.02] transition-transform w-80"
|
<div className="px-4 py-3">
|
||||||
onClick={() => setIsMinimized(false)}
|
{/* Progress bar at top for mobile */}
|
||||||
>
|
<div className="mb-3">
|
||||||
<div className="flex items-center p-3">
|
<Progress
|
||||||
<Image
|
value={progress}
|
||||||
src={currentTrack.coverArt || '/default-user.jpg'}
|
className="h-1 cursor-pointer progress-mobile"
|
||||||
alt={currentTrack.name}
|
onClick={handleProgressClick}
|
||||||
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'}`}
|
|
||||||
/>
|
/>
|
||||||
</button>
|
</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}>
|
<div className="flex items-center justify-between">
|
||||||
<FaBackward className="w-3 h-3" />
|
{/* Track info with swipe gestures */}
|
||||||
</button>
|
<div
|
||||||
<button className="p-2 hover:bg-gray-700/50 rounded-full transition-colors" onClick={togglePlayPause}>
|
className="flex items-center flex-1 min-w-0 cursor-pointer"
|
||||||
{isPlaying ? <FaPause className="w-4 h-4" /> : <FaPlay className="w-4 h-4" />}
|
onClick={() => setIsFullScreen(true)}
|
||||||
</button>
|
onTouchStart={handleTouchStart}
|
||||||
<button className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors" onClick={playNextTrack}>
|
onTouchMove={handleTouchMove}
|
||||||
<FaForward className="w-3 h-3" />
|
onTouchEnd={handleTouchEnd}
|
||||||
</button>
|
>
|
||||||
</div>
|
<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>
|
||||||
</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" />
|
<audio ref={preloadAudioRef} hidden preload="metadata" />
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compact floating player (default state)
|
// Desktop mini player (collapsed state)
|
||||||
return (
|
if (isMinimized) {
|
||||||
<div className="fixed bottom-4 left-4 right-4 z-50">
|
return (
|
||||||
<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">
|
<div className="fixed bottom-4 left-4 z-50">
|
||||||
{/* Track info */}
|
<div
|
||||||
<div className="flex items-center flex-1 min-w-0">
|
className="bg-background/95 backdrop-blur-xs border rounded-lg shadow-lg cursor-pointer hover:scale-[1.02] transition-transform w-80"
|
||||||
<Image
|
onClick={() => setIsMinimized(false)}
|
||||||
src={
|
>
|
||||||
currentTrack.coverArt &&
|
<div className="flex items-center p-3">
|
||||||
(currentTrack.coverArt.startsWith('http') || currentTrack.coverArt.startsWith('/'))
|
<Image
|
||||||
? currentTrack.coverArt
|
src={currentTrack.coverArt || '/default-user.jpg'}
|
||||||
: '/default-user.jpg'
|
alt={currentTrack.name}
|
||||||
}
|
width={40}
|
||||||
alt={currentTrack.name}
|
height={40}
|
||||||
width={48}
|
className="w-10 h-10 rounded-md shrink-0"
|
||||||
height={48}
|
/>
|
||||||
className="w-12 h-12 rounded-md mr-4 shrink-0"
|
<div className="flex-1 min-w-0 mx-3">
|
||||||
/>
|
<div className="overflow-hidden">
|
||||||
<div className="flex-1 min-w-0">
|
<p className="font-semibold text-sm whitespace-nowrap animate-infinite-scroll">
|
||||||
<p className="font-semibold truncate text-base">{currentTrack.name}</p>
|
{currentTrack.name}
|
||||||
<p className="text-sm text-muted-foreground truncate">{currentTrack.artist}</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p className="text-xs text-muted-foreground truncate">{currentTrack.artist}</p>
|
||||||
|
</div>
|
||||||
{/* Center section with controls and progress */}
|
{/* Heart icon for favoriting */}
|
||||||
<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
|
<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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
toggleCurrentTrackStar();
|
toggleCurrentTrackStar();
|
||||||
@@ -464,51 +850,144 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
title={currentTrack.starred ? 'Remove from favorites' : 'Add to favorites'}
|
title={currentTrack.starred ? 'Remove from favorites' : 'Add to favorites'}
|
||||||
>
|
>
|
||||||
<Heart
|
<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>
|
</button>
|
||||||
</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}>
|
||||||
{/* Progress bar */}
|
<FaBackward className="w-3 h-3" />
|
||||||
{/* <div className="flex items-center space-x-2 w-80">
|
</button>
|
||||||
<span className="text-xs text-muted-foreground w-8 text-right">
|
<button className="p-2 hover:bg-gray-700/50 rounded-full transition-colors" onClick={togglePlayPause}>
|
||||||
{formatTime(audioCurrent?.currentTime ?? 0)}
|
{isPlaying ? <FaPause className="w-4 h-4" /> : <FaPlay className="w-4 h-4" />}
|
||||||
</span>
|
</button>
|
||||||
<Progress value={progress} className="flex-1 cursor-pointer h-1" onClick={handleProgressClick}/>
|
<button className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors" onClick={playNextTrack}>
|
||||||
<span className="text-xs text-muted-foreground w-8">
|
<FaForward className="w-3 h-3" />
|
||||||
{formatTime(audioCurrent?.duration ?? 0)}
|
</button>
|
||||||
</span>
|
|
||||||
</div> */}
|
|
||||||
</div>
|
</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>
|
</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>
|
</div>
|
||||||
<audio ref={audioRef} hidden />
|
|
||||||
<audio ref={preloadAudioRef} hidden preload="metadata" />
|
|
||||||
|
|
||||||
{/* Full Screen Player */}
|
{/* Single audio element - shared across all UI states with mobile support */}
|
||||||
<FullScreenPlayer
|
<audio
|
||||||
isOpen={isFullScreen}
|
ref={audioRef}
|
||||||
onClose={() => setIsFullScreen(false)}
|
playsInline
|
||||||
onOpenQueue={handleOpenQueue}
|
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 [isLoading, setIsLoading] = useState(false);
|
||||||
const [shuffle, setShuffle] = useState(false);
|
const [shuffle, setShuffle] = useState(false);
|
||||||
const { toast } = useToast();
|
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(() => {
|
useEffect(() => {
|
||||||
const savedQueue = localStorage.getItem('navidrome-audioQueue');
|
const savedQueue = localStorage.getItem('navidrome-audioQueue');
|
||||||
@@ -98,14 +106,18 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
if (!api) {
|
if (!api) {
|
||||||
throw new Error('Navidrome API not configured');
|
throw new Error('Navidrome API not configured');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const streamUrl = api.getStreamUrl(song.id);
|
||||||
|
console.log('🎵 Creating track with stream URL:', streamUrl);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: song.id,
|
id: song.id,
|
||||||
name: song.title,
|
name: song.title,
|
||||||
url: api.getStreamUrl(song.id),
|
url: streamUrl,
|
||||||
artist: song.artist,
|
artist: song.artist,
|
||||||
album: song.album,
|
album: song.album,
|
||||||
duration: song.duration,
|
duration: song.duration,
|
||||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 512) : undefined,
|
||||||
albumId: song.albumId,
|
albumId: song.albumId,
|
||||||
artistId: song.artistId,
|
artistId: song.artistId,
|
||||||
starred: !!song.starred
|
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 (
|
return (
|
||||||
<Card className="break-inside-avoid">
|
<Card className="break-inside-avoid py-5">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Database className="h-5 w-5" />
|
<Database className="h-5 w-5" />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
|||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { lrcLibClient } from '@/lib/lrclib';
|
import { lrcLibClient } from '@/lib/lrclib';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
import {
|
import {
|
||||||
FaPlay,
|
FaPlay,
|
||||||
FaPause,
|
FaPause,
|
||||||
@@ -34,8 +35,20 @@ interface FullScreenPlayerProps {
|
|||||||
onOpenQueue?: () => void;
|
onOpenQueue?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MobileTab = 'player' | 'lyrics' | 'queue';
|
||||||
|
|
||||||
export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onClose, onOpenQueue }) => {
|
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 router = useRouter();
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
@@ -47,8 +60,19 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
const [lyrics, setLyrics] = useState<LyricLine[]>([]);
|
const [lyrics, setLyrics] = useState<LyricLine[]>([]);
|
||||||
const [currentLyricIndex, setCurrentLyricIndex] = useState(-1);
|
const [currentLyricIndex, setCurrentLyricIndex] = useState(-1);
|
||||||
const [showLyrics, setShowLyrics] = useState(true);
|
const [showLyrics, setShowLyrics] = useState(true);
|
||||||
|
const [activeTab, setActiveTab] = useState<MobileTab>('player');
|
||||||
const lyricsRef = useRef<HTMLDivElement>(null);
|
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
|
// Load lyrics when track changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadLyrics = async () => {
|
const loadLyrics = async () => {
|
||||||
@@ -72,7 +96,7 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
setLyrics([]);
|
setLyrics([]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load lyrics:', error);
|
console.log('Failed to load lyrics:', error);
|
||||||
setLyrics([]);
|
setLyrics([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -88,62 +112,106 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
}
|
}
|
||||||
}, [lyrics, currentTime, currentLyricIndex]);
|
}, [lyrics, currentTime, currentLyricIndex]);
|
||||||
|
|
||||||
// Auto-scroll lyrics using lyricsRef
|
// Auto-scroll lyrics using lyricsRef - Disabled on mobile to prevent iOS audio issues
|
||||||
useEffect(() => {
|
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(() => {
|
const scrollTimeout = setTimeout(() => {
|
||||||
// Find the ScrollArea viewport
|
try {
|
||||||
const scrollViewport = lyricsRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement;
|
const scrollContainer = lyricsRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement;
|
||||||
const currentLyricElement = lyricsRef.current?.querySelector(`[data-lyric-index="${currentLyricIndex}"]`) 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;
|
|
||||||
|
|
||||||
// Calculate scroll position to center the current lyric
|
if (scrollContainer && currentLyricElement) {
|
||||||
const targetScrollTop = elementTop - (containerHeight / 2) + (elementHeight / 2);
|
const containerHeight = scrollContainer.clientHeight;
|
||||||
|
const elementTop = currentLyricElement.offsetTop;
|
||||||
scrollViewport.scrollTo({
|
const elementHeight = currentLyricElement.offsetHeight;
|
||||||
top: Math.max(0, targetScrollTop),
|
const targetScrollTop = elementTop - (containerHeight / 2) + (elementHeight / 2);
|
||||||
behavior: 'smooth'
|
|
||||||
});
|
scrollContainer.scrollTo({
|
||||||
|
top: Math.max(0, targetScrollTop),
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Lyrics scroll failed:', error);
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 200);
|
||||||
|
|
||||||
return () => clearTimeout(scrollTimeout);
|
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(() => {
|
useEffect(() => {
|
||||||
if (currentTrack && showLyrics && lyricsRef.current) {
|
// Only reset scroll on desktop to avoid iOS audio interference
|
||||||
// Reset scroll position using lyricsRef
|
const shouldReset = !isMobile && showLyrics && lyrics.length > 0;
|
||||||
const resetScroll = () => {
|
|
||||||
const scrollViewport = lyricsRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement;
|
if (currentTrack?.id && shouldReset && lyricsRef.current) {
|
||||||
|
|
||||||
if (scrollViewport) {
|
|
||||||
scrollViewport.scrollTo({
|
|
||||||
top: 0,
|
|
||||||
behavior: 'instant' // Use instant for track changes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Small delay to ensure DOM is ready
|
|
||||||
const resetTimeout = setTimeout(() => {
|
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);
|
setCurrentLyricIndex(-1);
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|
||||||
return () => clearTimeout(resetTimeout);
|
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)
|
// Sync with main audio player (improved responsiveness)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const syncWithMainPlayer = () => {
|
const syncWithMainPlayer = () => {
|
||||||
const mainAudio = document.querySelector('audio') as HTMLAudioElement;
|
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) {
|
if (mainAudio && currentTrack) {
|
||||||
const newCurrentTime = mainAudio.currentTime;
|
const newCurrentTime = mainAudio.currentTime;
|
||||||
const newDuration = mainAudio.duration || 0;
|
const newDuration = mainAudio.duration || 0;
|
||||||
@@ -206,20 +274,96 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
setDominantColor(`rgb(${r}, ${g}, ${b})`);
|
setDominantColor(`rgb(${r}, ${g}, ${b})`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to extract color:', error);
|
console.log('Failed to extract color:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
img.src = currentTrack.coverArt;
|
img.src = currentTrack.coverArt;
|
||||||
}, [currentTrack]);
|
}, [currentTrack]);
|
||||||
|
|
||||||
const togglePlayPause = () => {
|
const togglePlayPause = () => {
|
||||||
const mainAudio = document.querySelector('audio') as HTMLAudioElement;
|
console.log('🎵 FullScreenPlayer Toggle Play/Pause clicked');
|
||||||
if (!mainAudio) return;
|
|
||||||
|
// Find the main audio player's play/pause button and click it
|
||||||
if (isPlaying) {
|
// This ensures we use the same logic as the main player
|
||||||
mainAudio.pause();
|
const mainPlayButton = document.querySelector('[data-testid="play-pause-button"]') as HTMLButtonElement;
|
||||||
|
|
||||||
|
if (mainPlayButton) {
|
||||||
|
console.log('✅ Found main play button, triggering click');
|
||||||
|
mainPlayButton.click();
|
||||||
} else {
|
} 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;
|
if (!isOpen || !currentTrack) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-black overflow-hidden">
|
<div className="fixed inset-0 z-[70] bg-black overflow-hidden">
|
||||||
{/* Blurred background image */}
|
{/* Enhanced Blurred background image */}
|
||||||
{currentTrack.coverArt && (
|
{currentTrack.coverArt && (
|
||||||
<div
|
<div className="absolute inset-0 w-full h-full">
|
||||||
className="absolute inset-0 w-full h-full"
|
{/* Main background */}
|
||||||
style={{
|
<div
|
||||||
backgroundImage: `url(${currentTrack.coverArt})`,
|
className="absolute inset-0 w-full h-full"
|
||||||
backgroundSize: '120%',
|
style={{
|
||||||
backgroundPosition: 'center',
|
backgroundImage: `url(${currentTrack.coverArt})`,
|
||||||
backgroundRepeat: 'no-repeat',
|
backgroundSize: 'cover',
|
||||||
filter: 'blur(20px) brightness(0.3)',
|
backgroundPosition: 'center',
|
||||||
transform: 'scale(1.1)',
|
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 */}
|
{/* Overlay for better contrast */}
|
||||||
<div className="absolute inset-0 bg-black/50" />
|
<div className="absolute inset-0 bg-black/30" />
|
||||||
<div className="relative h-full w-full">
|
|
||||||
{/* Floating Header */}
|
<div className="relative h-full w-full flex flex-col">
|
||||||
<div className="absolute top-0 right-0 z-50 p-4 lg:p-6">
|
|
||||||
<div className="flex items-center gap-2">
|
{/* Mobile Close Handle */}
|
||||||
{onOpenQueue && (
|
{isMobile && (
|
||||||
<button
|
<div className="flex justify-center py-4 px-4">
|
||||||
onClick={onOpenQueue}
|
<div
|
||||||
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
|
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-white hover:bg-white/20 p-2 rounded-full transition-colors flex items-center justify-center w-10 h-10"
|
className="cursor-pointer px-8 py-3 -mx-8 -my-3"
|
||||||
title="Close Player"
|
style={{ touchAction: 'manipulation' }}
|
||||||
>
|
>
|
||||||
<FaXmark className="w-5 h-5" />
|
<div className="w-8 h-1 bg-gray-300 rounded-full opacity-60" />
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Desktop Header */}
|
||||||
<div className="h-full flex flex-col lg:flex-row gap-4 lg:gap-8 p-4 lg:p-6 overflow-hidden">
|
{!isMobile && (
|
||||||
{/* Left Side - Album Art and Controls */}
|
<div className="absolute top-0 right-0 z-10 p-4 lg:p-6">
|
||||||
<div className="flex flex-col items-center justify-center min-h-0 flex-1 min-w-0">
|
<div className="flex items-center gap-2">
|
||||||
{/* Album Art */}
|
{onOpenQueue && (
|
||||||
<div className="relative mb-4 lg:mb-6 shrink-0">
|
<button
|
||||||
<Image
|
onClick={onOpenQueue}
|
||||||
src={currentTrack.coverArt || '/default-album.png'}
|
className="text-white hover:bg-white/20 p-2 rounded-full transition-colors flex items-center justify-center w-10 h-10"
|
||||||
alt={currentTrack.album}
|
title="Open Queue"
|
||||||
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'}
|
|
||||||
>
|
>
|
||||||
<FaQuoteLeft className="w-4 h-4 sm:w-5 sm:h-5" />
|
<FaListUl className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
{showVolumeSlider && (
|
onClick={onClose}
|
||||||
<div
|
className="text-white hover:bg-white/20 p-2 rounded-full transition-colors flex items-center justify-center w-10 h-10"
|
||||||
className="w-16 sm:w-20 lg:w-24"
|
title="Close Player"
|
||||||
onMouseLeave={() => setShowVolumeSlider(false)}
|
>
|
||||||
>
|
<FaXmark className="w-5 h-5" />
|
||||||
<input
|
</button>
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
value={volume * 100}
|
|
||||||
onChange={handleVolumeChange}
|
|
||||||
className="w-full accent-foreground"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Right Side - Lyrics */}
|
{/* Main Content */}
|
||||||
{showLyrics && lyrics.length > 0 && (
|
<div className="flex-1 overflow-hidden">
|
||||||
<div className="flex-1 min-w-0 min-h-0 flex flex-col" ref={lyricsRef}>
|
{isMobile ? (
|
||||||
<div className="h-full flex flex-col">
|
/* Mobile Tab Content */
|
||||||
<ScrollArea className="flex-1 min-h-0">
|
<div className="h-full flex flex-col">
|
||||||
<div className="space-y-2 sm:space-y-3 pl-4 pr-4 py-4">
|
<div className="flex-1 overflow-hidden">
|
||||||
{lyrics.map((line, index) => (
|
{activeTab === 'player' && (
|
||||||
<div
|
<div className="h-full flex flex-col justify-center items-center px-8 py-4">
|
||||||
key={index}
|
{/* Mobile Album Art */}
|
||||||
data-lyric-index={index}
|
<div className="relative mb-6 shrink-0">
|
||||||
onClick={() => handleLyricClick(line.time)}
|
<Image
|
||||||
className={`text-sm sm:text-base lg:text-base leading-relaxed transition-all duration-300 break-words cursor-pointer hover:text-foreground ${
|
src={currentTrack.coverArt || '/default-album.png'}
|
||||||
index === currentLyricIndex
|
alt={currentTrack.album}
|
||||||
? 'text-foreground font-bold text-2xl'
|
width={260}
|
||||||
: index < currentLyricIndex
|
height={260}
|
||||||
? 'text-foreground/60'
|
className={`rounded-lg shadow-2xl object-cover transition-all duration-300 ${
|
||||||
: 'text-foreground/40'
|
!isPlaying ? 'w-52 h-52 opacity-70 scale-95' : 'w-64 h-64'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
priority
|
||||||
wordWrap: 'break-word',
|
/>
|
||||||
overflowWrap: 'break-word',
|
</div>
|
||||||
hyphens: 'auto',
|
|
||||||
paddingBottom: '4px',
|
{/* Track Info - Left Aligned and Heart on Same Line */}
|
||||||
paddingLeft: '8px'
|
<div className="w-full mb-6 shrink-0">
|
||||||
}}
|
<div className="flex items-center justify-between mb-0">
|
||||||
title={`Click to jump to ${formatTime(line.time)}`}
|
<h1 className="text-2xl font-bold text-foreground line-clamp-1 flex-1 text-left">
|
||||||
>
|
{currentTrack.name}
|
||||||
{line.text || '♪'}
|
</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>
|
</div>
|
||||||
))}
|
<Link
|
||||||
{/* Add extra padding at the bottom to allow last lyric to center */}
|
href={`/artist/${currentTrack.artistId}`}
|
||||||
<div style={{ height: '200px' }} />
|
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>
|
</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>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function PopularSongs({ songs, artistName }: PopularSongsProps) {
|
|||||||
artist: song.artist,
|
artist: song.artist,
|
||||||
album: song.album,
|
album: song.album,
|
||||||
duration: song.duration,
|
duration: song.duration,
|
||||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 1200) : undefined,
|
||||||
albumId: song.albumId,
|
albumId: song.albumId,
|
||||||
artistId: song.artistId,
|
artistId: song.artistId,
|
||||||
starred: !!song.starred
|
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">
|
<div className="relative w-12 h-12 bg-muted rounded-md overflow-hidden shrink-0">
|
||||||
{song.coverArt && api && (
|
{song.coverArt && api && (
|
||||||
<Image
|
<Image
|
||||||
src={api.getCoverArtUrl(song.coverArt, 96)}
|
src={api.getCoverArtUrl(song.coverArt, 300)}
|
||||||
alt={song.album}
|
alt={song.album}
|
||||||
width={48}
|
width={48}
|
||||||
height={48}
|
height={48}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { PostHogProvider } from "../components/PostHogProvider";
|
|||||||
import { WhatsNewPopup } from "../components/WhatsNewPopup";
|
import { WhatsNewPopup } from "../components/WhatsNewPopup";
|
||||||
import Ihateserverside from "./ihateserverside";
|
import Ihateserverside from "./ihateserverside";
|
||||||
import DynamicViewportTheme from "./DynamicViewportTheme";
|
import DynamicViewportTheme from "./DynamicViewportTheme";
|
||||||
|
import ThemeColorHandler from "./ThemeColorHandler";
|
||||||
|
import { useViewportThemeColor } from "@/hooks/use-viewport-theme-color";
|
||||||
import { LoginForm } from "./start-screen";
|
import { LoginForm } from "./start-screen";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
@@ -83,6 +85,7 @@ export default function RootLayoutClient({ children }: { children: React.ReactNo
|
|||||||
<PostHogProvider>
|
<PostHogProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<DynamicViewportTheme />
|
<DynamicViewportTheme />
|
||||||
|
<ThemeColorHandler />
|
||||||
<NavidromeConfigProvider>
|
<NavidromeConfigProvider>
|
||||||
<NavidromeProvider>
|
<NavidromeProvider>
|
||||||
<NavidromeErrorBoundary>
|
<NavidromeErrorBoundary>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function SettingsManagement() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="py-5">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Settings className="h-5 w-5" />
|
<Settings className="h-5 w-5" />
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export function SidebarCustomization() {
|
|||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="py-5">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Sidebar Customization</CardTitle>
|
<CardTitle>Sidebar Customization</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { Song } from '@/lib/navidrome';
|
import { Song, Album } from '@/lib/navidrome';
|
||||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||||
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Play, Heart, Music, Shuffle } from 'lucide-react';
|
import { Play, Heart, Music, Shuffle } from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { UserProfile } from './UserProfile';
|
||||||
|
|
||||||
interface SongRecommendationsProps {
|
interface SongRecommendationsProps {
|
||||||
userName?: string;
|
userName?: string;
|
||||||
@@ -17,14 +19,26 @@ interface SongRecommendationsProps {
|
|||||||
export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
||||||
const { api, isConnected } = useNavidrome();
|
const { api, isConnected } = useNavidrome();
|
||||||
const { playTrack, shuffle, toggleShuffle } = useAudioPlayer();
|
const { playTrack, shuffle, toggleShuffle } = useAudioPlayer();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const [recommendedSongs, setRecommendedSongs] = useState<Song[]>([]);
|
const [recommendedSongs, setRecommendedSongs] = useState<Song[]>([]);
|
||||||
|
const [recommendedAlbums, setRecommendedAlbums] = useState<Album[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [songStates, setSongStates] = useState<Record<string, boolean>>({});
|
const [songStates, setSongStates] = useState<Record<string, boolean>>({});
|
||||||
const [imageLoadingStates, setImageLoadingStates] = useState<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
// Get greeting based on time of day
|
// Memoize the greeting to prevent recalculation
|
||||||
const hour = new Date().getHours();
|
const greeting = useMemo(() => {
|
||||||
const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
|
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(() => {
|
useEffect(() => {
|
||||||
const loadRecommendations = async () => {
|
const loadRecommendations = async () => {
|
||||||
@@ -32,43 +46,47 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Get random albums and extract songs from them
|
// Get random albums for both mobile album view and desktop song extraction
|
||||||
const randomAlbums = await api.getAlbums('random', 10); // Get 10 random albums
|
const randomAlbums = await api.getAlbums('random', 10);
|
||||||
const allSongs: Song[] = [];
|
|
||||||
|
|
||||||
// Get songs from first few albums
|
if (isMobile) {
|
||||||
for (let i = 0; i < Math.min(3, randomAlbums.length); i++) {
|
// For mobile: show 6 random albums
|
||||||
try {
|
setRecommendedAlbums(randomAlbums.slice(0, 6));
|
||||||
const albumSongs = await api.getAlbumSongs(randomAlbums[i].id);
|
} else {
|
||||||
allSongs.push(...albumSongs);
|
// For desktop: extract songs from albums (original behavior)
|
||||||
} catch (error) {
|
const allSongs: Song[] = [];
|
||||||
console.error('Failed to get album songs:', error);
|
|
||||||
|
// 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) {
|
} catch (error) {
|
||||||
console.error('Failed to load song recommendations:', error);
|
console.error('Failed to load recommendations:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadRecommendations();
|
loadRecommendations();
|
||||||
}, [api, isConnected]);
|
}, [api, isConnected, isMobile]);
|
||||||
|
|
||||||
const handlePlaySong = async (song: Song) => {
|
const handlePlaySong = async (song: Song) => {
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
@@ -83,7 +101,7 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
|||||||
album: song.album || 'Unknown Album',
|
album: song.album || 'Unknown Album',
|
||||||
albumId: song.albumId || '',
|
albumId: song.albumId || '',
|
||||||
duration: song.duration || 0,
|
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
|
starred: !!song.starred
|
||||||
};
|
};
|
||||||
await playTrack(track, true);
|
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 () => {
|
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
|
// Enable shuffle if not already on
|
||||||
if (!shuffle) {
|
if (!shuffle) {
|
||||||
toggleShuffle();
|
toggleShuffle();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play a random song from recommendations
|
if (isMobile) {
|
||||||
const randomSong = recommendedSongs[Math.floor(Math.random() * recommendedSongs.length)];
|
// Play a random album
|
||||||
await handlePlaySong(randomSong);
|
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 => {
|
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-8 w-48 bg-muted animate-pulse rounded" />
|
||||||
<div className="h-4 w-64 bg-muted animate-pulse rounded" />
|
<div className="h-4 w-64 bg-muted animate-pulse rounded" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
{isMobile ? (
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div key={i} className="h-16 bg-muted animate-pulse rounded" />
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
))}
|
<div key={i} className="aspect-square bg-muted animate-pulse rounded" />
|
||||||
</div>
|
))}
|
||||||
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -135,95 +194,153 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
|
|||||||
{greeting}{userName ? `, ${userName}` : ''}!
|
{greeting}{userName ? `, ${userName}` : ''}!
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{recommendedSongs.length > 0 && (
|
<div className="flex items-center gap-3">
|
||||||
<Button onClick={handleShuffleAll} variant="outline" size="sm">
|
{/* Mobile User Profile */}
|
||||||
<Shuffle className="w-4 h-4 mr-2" />
|
{isMobile && <UserProfile variant="mobile" />}
|
||||||
Shuffle All
|
|
||||||
</Button>
|
{/* 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>
|
</div>
|
||||||
|
|
||||||
{recommendedSongs.length > 0 ? (
|
{isMobile ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
/* Mobile: Show albums in 3x2 grid */
|
||||||
{recommendedSongs.map((song) => (
|
recommendedAlbums.length > 0 ? (
|
||||||
<Card
|
<div className="grid grid-cols-3 gap-3">
|
||||||
key={song.id}
|
{recommendedAlbums.map((album) => (
|
||||||
className="group cursor-pointer hover:bg-accent/50 transition-colors py-2"
|
<div key={album.id} className="space-y-2">
|
||||||
onClick={() => handlePlaySong(song)}
|
<Link
|
||||||
>
|
href={`/album/${album.id}`}
|
||||||
<CardContent className="px-2">
|
className="group cursor-pointer block"
|
||||||
<div className="flex items-center gap-3">
|
>
|
||||||
<div className="relative w-12 h-12 rounded overflow-hidden bg-muted flex-shrink-0">
|
<div className="relative aspect-square rounded-lg overflow-hidden bg-muted">
|
||||||
{song.coverArt && api ? (
|
{album.coverArt && api ? (
|
||||||
<>
|
<Image
|
||||||
{imageLoadingStates[song.id] && (
|
src={api.getCoverArtUrl(album.coverArt, 300)}
|
||||||
<div className="absolute inset-0 bg-muted flex items-center justify-center">
|
alt={album.name}
|
||||||
<Music className="w-6 h-6 text-muted-foreground animate-pulse" />
|
width={600}
|
||||||
</div>
|
height={600}
|
||||||
)}
|
className="object-cover"
|
||||||
<Image
|
sizes="(max-width: 768px) 33vw, 200px"
|
||||||
src={api.getCoverArtUrl(song.coverArt, 100)}
|
onLoad={handleImageLoad}
|
||||||
alt={song.title}
|
onError={handleImageError}
|
||||||
fill
|
loading="lazy"
|
||||||
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 }))}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
<Music className="w-6 h-6 text-muted-foreground" />
|
<Music className="w-8 h-8 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" />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="space-y-1">
|
||||||
<p className="font-medium truncate">{song.title}</p>
|
<Link
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
href={`/album/${album.id}`}
|
||||||
<Link
|
className="font-medium text-sm truncate hover:underline block"
|
||||||
href={`/artist/${song.artistId}`}
|
>
|
||||||
className="hover:underline truncate"
|
{album.name}
|
||||||
onClick={(e) => e.stopPropagation()}
|
</Link>
|
||||||
>
|
<Link
|
||||||
{song.artist}
|
href={`/artist/${album.artistId || album.artist}`}
|
||||||
</Link>
|
className="text-xs text-muted-foreground truncate hover:underline block"
|
||||||
{song.duration && (
|
>
|
||||||
|
{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>
|
<Image
|
||||||
<span>{formatDuration(song.duration)}</span>
|
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>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
|
</CardContent>
|
||||||
{songStates[song.id] && (
|
</Card>
|
||||||
<Heart className="w-4 h-4 text-primary flex-shrink-0" fill="currentColor" />
|
))}
|
||||||
)}
|
</div>
|
||||||
</div>
|
) : (
|
||||||
</CardContent>
|
<Card>
|
||||||
</Card>
|
<CardContent className="p-6 text-center">
|
||||||
))}
|
<Music className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||||
</div>
|
<p className="text-muted-foreground">
|
||||||
) : (
|
No songs available for recommendations
|
||||||
<Card>
|
</p>
|
||||||
<CardContent className="p-6 text-center">
|
</CardContent>
|
||||||
<Music className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
</Card>
|
||||||
<p className="text-muted-foreground">
|
)
|
||||||
No songs available for recommendations
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
||||||
|
|
||||||
// Current app version from package.json
|
// 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
|
// Changelog data - add new versions at the top
|
||||||
const CHANGELOG = [
|
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',
|
version: '2025.07.10',
|
||||||
title: 'July Major Update',
|
title: 'July Major Update',
|
||||||
@@ -189,65 +206,86 @@ export function WhatsNewPopup() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
<>
|
||||||
<DialogContent className="max-w-2xl max-h-[80vh]">
|
{isOpen && (
|
||||||
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<div>
|
{/* Backdrop */}
|
||||||
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
|
<div
|
||||||
What's New in Mice
|
className="fixed inset-0 bg-black/50"
|
||||||
<Badge variant="outline">
|
onClick={handleClose}
|
||||||
{tab === 'latest' ? currentVersionChangelog.version : archiveChangelog?.version}
|
/>
|
||||||
</Badge>
|
|
||||||
</DialogTitle>
|
{/* 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>
|
</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>
|
</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 Link from "next/link";
|
||||||
import { useAudioPlayer, Track } from "@/app/components/AudioPlayerContext";
|
import { useAudioPlayer, Track } from "@/app/components/AudioPlayerContext";
|
||||||
import { getNavidromeAPI } from "@/lib/navidrome";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { ArtistIcon } from "@/app/components/artist-icon";
|
import { ArtistIcon } from "@/app/components/artist-icon";
|
||||||
@@ -46,8 +46,24 @@ export function AlbumArtwork({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { addAlbumToQueue, playTrack, addToQueue } = useAudioPlayer();
|
const { addAlbumToQueue, playTrack, addToQueue } = useAudioPlayer();
|
||||||
const { playlists, starItem, unstarItem } = useNavidrome();
|
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 = () => {
|
const handleClick = () => {
|
||||||
router.push(`/album/${album.id}`);
|
router.push(`/album/${album.id}`);
|
||||||
@@ -80,7 +96,7 @@ export function AlbumArtwork({
|
|||||||
artistId: song.artistId,
|
artistId: song.artistId,
|
||||||
url: api.getStreamUrl(song.id),
|
url: api.getStreamUrl(song.id),
|
||||||
duration: song.duration,
|
duration: song.duration,
|
||||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt) : undefined,
|
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 1200) : undefined,
|
||||||
starred: !!song.starred
|
starred: !!song.starred
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -105,68 +121,42 @@ export function AlbumArtwork({
|
|||||||
console.error('Failed to toggle favorite:', error);
|
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 (
|
return (
|
||||||
<div className={cn("space-y-3", className)} {...props}>
|
<div className={cn("space-y-3", className)} {...props}>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger>
|
<ContextMenuTrigger>
|
||||||
<Card key={album.id} className="overflow-hidden cursor-pointer px-0 py-0 gap-0" onClick={() => handleClick()}>
|
<Card key={album.id} className="overflow-hidden cursor-pointer px-0 py-0 gap-0" onClick={() => handleClick()}>
|
||||||
<div className="aspect-square relative group">
|
<div className="aspect-square relative group">
|
||||||
{album.coverArt && api ? (
|
{album.coverArt && api ? (
|
||||||
<>
|
<Image
|
||||||
{imageLoading && (
|
src={coverArtUrl}
|
||||||
<div className="absolute inset-0 bg-muted animate-pulse rounded flex items-center justify-center">
|
alt={album.name}
|
||||||
<Disc className="w-12 h-12 text-muted-foreground animate-spin" />
|
fill
|
||||||
</div>
|
className="w-full h-full object-cover transition-all"
|
||||||
)}
|
sizes="(max-width: 768px) 100vw, 300px"
|
||||||
<Image
|
onLoad={handleImageLoad}
|
||||||
src={api.getCoverArtUrl(album.coverArt)}
|
onError={handleImageError}
|
||||||
alt={album.name}
|
priority={false}
|
||||||
fill
|
loading="lazy"
|
||||||
className={`w-full h-full object-cover transition-opacity duration-300 ${
|
/>
|
||||||
imageLoading ? 'opacity-0' : 'opacity-100'
|
) : (
|
||||||
}`}
|
<div className="w-full h-full bg-muted rounded flex items-center justify-center">
|
||||||
sizes="(max-width: 768px) 100vw, 300px"
|
<Disc className="w-12 h-12 text-muted-foreground" />
|
||||||
onLoad={() => setImageLoading(false)}
|
</div>
|
||||||
onError={() => {
|
)}
|
||||||
setImageLoading(false);
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||||
setImageError(true);
|
<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>
|
||||||
<div className="w-full h-full bg-muted rounded flex items-center justify-center">
|
<p className="text-sm text-muted-foreground truncate " onClick={() => router.push(album.artistId)}>{album.artist}</p>
|
||||||
<Disc className="w-12 h-12 text-muted-foreground" />
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
</div>
|
{album.songCount} songs • {Math.floor(album.duration / 60)} min
|
||||||
)}
|
</p>
|
||||||
{!imageLoading && (
|
</CardContent>
|
||||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
</Card>
|
||||||
<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 onClick={handleClick} className="overflow-hidden rounded-md">
|
{/* <div onClick={handleClick} className="overflow-hidden rounded-md">
|
||||||
<Image
|
<Image
|
||||||
src={coverArtUrl}
|
src={coverArtUrl}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Menu } from "@/app/components/menu";
|
|||||||
import { Sidebar } from "@/app/components/sidebar";
|
import { Sidebar } from "@/app/components/sidebar";
|
||||||
import { useNavidrome } from "@/app/components/NavidromeContext";
|
import { useNavidrome } from "@/app/components/NavidromeContext";
|
||||||
import { AudioPlayer } from "./AudioPlayer";
|
import { AudioPlayer } from "./AudioPlayer";
|
||||||
|
import { BottomNavigation } from './BottomNavigation';
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import { useFavoriteAlbums } from "@/hooks/use-favorite-albums";
|
import { useFavoriteAlbums } from "@/hooks/use-favorite-albums";
|
||||||
|
|
||||||
@@ -96,48 +97,74 @@ const Ihateserverside: React.FC<IhateserversideProps> = ({ children }) => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="hidden md:flex md:flex-col md:h-screen md:w-screen md:overflow-hidden">
|
<>
|
||||||
{/* Top Menu */}
|
{/* Mobile Layout */}
|
||||||
<div
|
<div className="flex md:hidden flex-col h-screen w-screen overflow-hidden">
|
||||||
className="sticky z-10 bg-background border-b w-full"
|
{/* Top Menu */}
|
||||||
style={{
|
{/* <div className="shrink-0 bg-background border-b w-full">
|
||||||
left: 'env(titlebar-area-x, 0)',
|
<Menu
|
||||||
top: 'env(titlebar-area-y, 0)',
|
toggleSidebar={toggleSidebarVisibility}
|
||||||
}}
|
isSidebarVisible={isSidebarVisible}
|
||||||
>
|
toggleStatusBar={() => setIsStatusBarVisible(!isStatusBarVisible)}
|
||||||
<Menu
|
isStatusBarVisible={isStatusBarVisible}
|
||||||
toggleSidebar={toggleSidebarVisibility}
|
/>
|
||||||
isSidebarVisible={isSidebarVisible}
|
</div> */}
|
||||||
toggleStatusBar={() => setIsStatusBarVisible(!isStatusBarVisible)}
|
|
||||||
isStatusBarVisible={isStatusBarVisible}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area with bottom padding for audio player and bottom nav */}
|
||||||
<div className="flex-1 flex overflow-hidden w-full">
|
<div className="flex-1 overflow-y-auto pb-40">
|
||||||
{isSidebarVisible && (
|
<div>{children}</div>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Floating Audio Player */}
|
{/* Bottom Navigation for Mobile */}
|
||||||
{isStatusBarVisible && (
|
<BottomNavigation />
|
||||||
<AudioPlayer />
|
|
||||||
)}
|
<Toaster />
|
||||||
<Toaster />
|
</div>
|
||||||
</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 { useCallback } from "react";
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Image from "next/image";
|
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 {
|
import {
|
||||||
Menubar,
|
Menubar,
|
||||||
MenubarCheckboxItem,
|
MenubarCheckboxItem,
|
||||||
@@ -28,9 +29,35 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
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 { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
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 {
|
interface MenuProps {
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
@@ -43,9 +70,27 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
|
|||||||
const [isFullScreen, setIsFullScreen] = useState(false)
|
const [isFullScreen, setIsFullScreen] = useState(false)
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const { isConnected } = useNavidrome();
|
const { isConnected } = useNavidrome();
|
||||||
const [isClient, setIsClient] = useState(false);
|
const [isClient, setIsClient] = useState(false);
|
||||||
const [navidromeUrl, setNavidromeUrl] = useState<string | null>(null);
|
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
|
// For this demo, we'll show connection status instead of user auth
|
||||||
const connectionStatus = isConnected ? "Connected to Navidrome" : "Not connected";
|
const connectionStatus = isConnected ? "Connected to Navidrome" : "Not connected";
|
||||||
@@ -112,28 +157,35 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
<Menubar
|
{/* Mobile Top Bar - Simplified since navigation is now at bottom */}
|
||||||
className="rounded-none border-b border-none px-2 lg:px-2 flex-1 min-w-0"
|
{isMobile ? (
|
||||||
style={{
|
// hey bear!
|
||||||
minWidth: 0,
|
// nothing
|
||||||
WebkitAppRegion: "drag"
|
null
|
||||||
} as React.CSSProperties}
|
) : (
|
||||||
>
|
/* Desktop Navigation */
|
||||||
<div style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} className="flex items-center gap-2">
|
<Menubar
|
||||||
<MenubarMenu>
|
className="rounded-none border-b border-none px-2 lg:px-2 flex-1 min-w-0"
|
||||||
<MenubarTrigger className="font-bold">mice</MenubarTrigger>
|
style={{
|
||||||
<MenubarContent>
|
minWidth: 0,
|
||||||
<MenubarItem onClick={() => setOpen(true)}>About Music</MenubarItem>
|
WebkitAppRegion: "drag"
|
||||||
<MenubarSeparator />
|
} as React.CSSProperties}
|
||||||
<MenubarItem onClick={() => router.push('/settings')}>
|
>
|
||||||
Preferences <MenubarShortcut>⌘,</MenubarShortcut>
|
<div style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} className="flex items-center gap-2">
|
||||||
</MenubarItem>
|
<MenubarMenu>
|
||||||
<MenubarSeparator />
|
<MenubarTrigger className="font-bold">mice</MenubarTrigger>
|
||||||
<MenubarItem onClick={() => isClient && window.close()}>
|
<MenubarContent>
|
||||||
Quit Music <MenubarShortcut>⌘Q</MenubarShortcut>
|
<MenubarItem onClick={() => setOpen(true)}>About Music</MenubarItem>
|
||||||
</MenubarItem>
|
<MenubarSeparator />
|
||||||
</MenubarContent>
|
<MenubarItem onClick={() => router.push('/settings')}>
|
||||||
</MenubarMenu>
|
Preferences <MenubarShortcut>⌘,</MenubarShortcut>
|
||||||
|
</MenubarItem>
|
||||||
|
<MenubarSeparator />
|
||||||
|
<MenubarItem onClick={() => isClient && window.close()}>
|
||||||
|
Quit Music <MenubarShortcut>⌘Q</MenubarShortcut>
|
||||||
|
</MenubarItem>
|
||||||
|
</MenubarContent>
|
||||||
|
</MenubarMenu>
|
||||||
<MenubarMenu>
|
<MenubarMenu>
|
||||||
<MenubarTrigger className="relative">File</MenubarTrigger>
|
<MenubarTrigger className="relative">File</MenubarTrigger>
|
||||||
<MenubarContent>
|
<MenubarContent>
|
||||||
@@ -279,6 +331,14 @@ export function Menu({ toggleSidebar, isSidebarVisible, toggleStatusBar, isStatu
|
|||||||
</MenubarMenu>
|
</MenubarMenu>
|
||||||
</div>
|
</div>
|
||||||
</Menubar>
|
</Menubar>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User Profile - Desktop only */}
|
||||||
|
{!isMobile && (
|
||||||
|
<div className="ml-auto">
|
||||||
|
<UserProfile variant="desktop" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export function Sidebar({ className, playlists, visible = true, favoriteAlbums =
|
|||||||
>
|
>
|
||||||
{album.coverArt && api ? (
|
{album.coverArt && api ? (
|
||||||
<Image
|
<Image
|
||||||
src={api.getCoverArtUrl(album.coverArt, 32)}
|
src={api.getCoverArtUrl(album.coverArt, 150)}
|
||||||
alt={album.name}
|
alt={album.name}
|
||||||
width={16}
|
width={16}
|
||||||
height={16}
|
height={16}
|
||||||
@@ -165,7 +165,7 @@ export function Sidebar({ className, playlists, visible = true, favoriteAlbums =
|
|||||||
>
|
>
|
||||||
{album.coverArt && api ? (
|
{album.coverArt && api ? (
|
||||||
<Image
|
<Image
|
||||||
src={api.getCoverArtUrl(album.coverArt, 32)}
|
src={api.getCoverArtUrl(album.coverArt, 150)}
|
||||||
alt={album.name}
|
alt={album.name}
|
||||||
width={16}
|
width={16}
|
||||||
height={16}
|
height={16}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { useNavidromeConfig } from '@/app/components/NavidromeConfigContext';
|
import { useNavidromeConfig } from '@/app/components/NavidromeConfigContext';
|
||||||
import { useTheme } from '@/app/components/ThemeProvider';
|
import { useTheme } from '@/app/components/ThemeProvider';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
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({
|
export function LoginForm({
|
||||||
className,
|
className,
|
||||||
@@ -45,20 +45,7 @@ export function LoginForm({
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// New settings
|
// New settings - removed sidebar and standalone lastfm options
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if Navidrome is configured via environment variables
|
// Check if Navidrome is configured via environment variables
|
||||||
const hasEnvConfig = React.useMemo(() => {
|
const hasEnvConfig = React.useMemo(() => {
|
||||||
@@ -187,8 +174,6 @@ export function LoginForm({
|
|||||||
const handleFinishSetup = () => {
|
const handleFinishSetup = () => {
|
||||||
// Save all settings
|
// Save all settings
|
||||||
localStorage.setItem('lastfm-scrobbling-enabled', scrobblingEnabled.toString());
|
localStorage.setItem('lastfm-scrobbling-enabled', scrobblingEnabled.toString());
|
||||||
localStorage.setItem('sidebar-collapsed', sidebarCollapsed.toString());
|
|
||||||
localStorage.setItem('standalone-lastfm-enabled', standaloneLastfmEnabled.toString());
|
|
||||||
|
|
||||||
// Mark onboarding as complete
|
// Mark onboarding as complete
|
||||||
localStorage.setItem('onboarding-completed', '1.1.0');
|
localStorage.setItem('onboarding-completed', '1.1.0');
|
||||||
@@ -252,7 +237,7 @@ export function LoginForm({
|
|||||||
if (step === 'settings') {
|
if (step === 'settings') {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||||
<Card>
|
<Card className='py-5'>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<FaPalette className="w-5 h-5" />
|
<FaPalette className="w-5 h-5" />
|
||||||
@@ -286,29 +271,6 @@ export function LoginForm({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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 */}
|
{/* Last.fm Scrobbling */}
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<Label className="flex items-center gap-2">
|
<Label className="flex items-center gap-2">
|
||||||
@@ -334,31 +296,6 @@ export function LoginForm({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="flex flex-col gap-3">
|
||||||
<Button onClick={handleFinishSetup} className="w-full">
|
<Button onClick={handleFinishSetup} className="w-full">
|
||||||
<FaCheck className="w-4 h-4 mr-2" />
|
<FaCheck className="w-4 h-4 mr-2" />
|
||||||
@@ -383,7 +320,7 @@ export function LoginForm({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||||
<Card>
|
<Card className="py-5">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<FaServer className="w-5 h-5" />
|
<FaServer className="w-5 h-5" />
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ const FavoritesPage = () => {
|
|||||||
artistId: song.artistId,
|
artistId: song.artistId,
|
||||||
url: api?.getStreamUrl(song.id) || '',
|
url: api?.getStreamUrl(song.id) || '',
|
||||||
duration: song.duration,
|
duration: song.duration,
|
||||||
coverArt: song.coverArt ? api?.getCoverArtUrl(song.coverArt) : undefined,
|
coverArt: song.coverArt ? api?.getCoverArtUrl(song.coverArt, 1200) : undefined,
|
||||||
starred: !!song.starred
|
starred: !!song.starred
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -78,7 +78,7 @@ const FavoritesPage = () => {
|
|||||||
artistId: song.artistId,
|
artistId: song.artistId,
|
||||||
url: api.getStreamUrl(song.id),
|
url: api.getStreamUrl(song.id),
|
||||||
duration: song.duration,
|
duration: song.duration,
|
||||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt) : undefined,
|
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 1200) : undefined,
|
||||||
starred: !!song.starred
|
starred: !!song.starred
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -201,7 +201,7 @@ const FavoritesPage = () => {
|
|||||||
<div className="w-12 h-12 relative shrink-0">
|
<div className="w-12 h-12 relative shrink-0">
|
||||||
{song.coverArt && api ? (
|
{song.coverArt && api ? (
|
||||||
<Image
|
<Image
|
||||||
src={api.getCoverArtUrl(song.coverArt)}
|
src={api.getCoverArtUrl(song.coverArt, 1200)}
|
||||||
alt={song.album}
|
alt={song.album}
|
||||||
fill
|
fill
|
||||||
className="rounded object-cover"
|
className="rounded object-cover"
|
||||||
|
|||||||
198
app/globals.css
@@ -88,6 +88,18 @@
|
|||||||
body {
|
body {
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
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 {
|
@layer utilities {
|
||||||
@@ -816,34 +828,170 @@
|
|||||||
---break---
|
---break---
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/*
|
/* Mobile-specific optimizations */
|
||||||
|
@media (max-width: 767px) {
|
||||||
will delete after the new theme replaces the old one
|
/* Improve touch targets for mobile */
|
||||||
since the new theme already has the sidebar colors defined
|
button {
|
||||||
|
min-height: 44px;
|
||||||
:root {
|
min-width: 44px;
|
||||||
--sidebar: hsl(0 0% 98%);
|
}
|
||||||
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
|
||||||
--sidebar-primary: hsl(240 5.9% 10%);
|
/* Better touch feedback */
|
||||||
--sidebar-primary-foreground: hsl(0 0% 98%);
|
button:active {
|
||||||
--sidebar-accent: hsl(240 4.8% 95.9%);
|
transform: scale(0.95);
|
||||||
--sidebar-accent-foreground: hsl(240 5.9% 10%);
|
transition: transform 0.1s ease;
|
||||||
--sidebar-border: hsl(220 13% 91%);
|
}
|
||||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
|
||||||
|
/* 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 {
|
/* Safe area support for mobile devices */
|
||||||
--sidebar: hsl(240 5.9% 10%);
|
.pb-safe {
|
||||||
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
padding-bottom: env(safe-area-inset-bottom, 0.5rem);
|
||||||
--sidebar-primary: hsl(224.3 76.3% 48%);
|
}
|
||||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
|
||||||
--sidebar-accent: hsl(240 3.7% 15.9%);
|
.mobile-safe-bottom {
|
||||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
margin-bottom: env(safe-area-inset-bottom, 0);
|
||||||
--sidebar-border: hsl(240 3.7% 15.9%);
|
}
|
||||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
|
||||||
} */
|
/* 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---
|
---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,
|
'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({
|
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>
|
<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) => {
|
<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
|
const playlistCoverUrl = playlist.coverArt && api
|
||||||
? api.getCoverArtUrl(playlist.coverArt, 200)
|
? api.getCoverArtUrl(playlist.coverArt, 600)
|
||||||
: '/default-user.jpg';
|
: '/default-user.jpg';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export default function SongsPage() {
|
|||||||
|
|
||||||
setFilteredSongs(filtered);
|
setFilteredSongs(filtered);
|
||||||
}, [songs, searchQuery, sortBy, sortDirection]);
|
}, [songs, searchQuery, sortBy, sortDirection]);
|
||||||
const handlePlaySong = (song: Song) => {
|
const handlePlayClick = (song: Song) => {
|
||||||
if (!api) {
|
if (!api) {
|
||||||
console.error('Navidrome API not available');
|
console.error('Navidrome API not available');
|
||||||
return;
|
return;
|
||||||
@@ -114,7 +114,7 @@ export default function SongsPage() {
|
|||||||
artist: song.artist,
|
artist: song.artist,
|
||||||
album: song.album,
|
album: song.album,
|
||||||
duration: song.duration,
|
duration: song.duration,
|
||||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined,
|
||||||
albumId: song.albumId,
|
albumId: song.albumId,
|
||||||
artistId: song.artistId,
|
artistId: song.artistId,
|
||||||
starred: !!song.starred
|
starred: !!song.starred
|
||||||
@@ -135,7 +135,7 @@ export default function SongsPage() {
|
|||||||
artist: song.artist,
|
artist: song.artist,
|
||||||
album: song.album,
|
album: song.album,
|
||||||
duration: song.duration,
|
duration: song.duration,
|
||||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined,
|
||||||
albumId: song.albumId,
|
albumId: song.albumId,
|
||||||
artistId: song.artistId,
|
artistId: song.artistId,
|
||||||
starred: !!song.starred
|
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 ${
|
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' : ''
|
isCurrentlyPlaying(song) ? 'bg-accent/50 border-l-4 border-primary' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handlePlaySong(song)}
|
onClick={() => handlePlayClick(song)}
|
||||||
>
|
>
|
||||||
{/* Track Number / Play Indicator */}
|
{/* Track Number / Play Indicator */}
|
||||||
<div className="w-8 text-center text-sm text-muted-foreground mr-3">
|
<div className="w-8 text-center text-sm text-muted-foreground mr-3">
|
||||||
@@ -240,7 +240,7 @@ export default function SongsPage() {
|
|||||||
|
|
||||||
{/* Album Art */}
|
{/* Album Art */}
|
||||||
<div className="w-12 h-12 mr-4 shrink-0"> <Image
|
<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}
|
alt={song.album}
|
||||||
width={48}
|
width={48}
|
||||||
height={48}
|
height={48}
|
||||||
|
|||||||
@@ -38,6 +38,25 @@ export default function manifest(): MetadataRoute.Manifest {
|
|||||||
type: 'image/png',
|
type: 'image/png',
|
||||||
sizes: '512x512',
|
sizes: '512x512',
|
||||||
purpose: 'maskable'
|
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: [
|
screenshots: [
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { useSearchParams } from 'next/navigation';
|
|||||||
import { useAudioPlayer } from './components/AudioPlayerContext';
|
import { useAudioPlayer } from './components/AudioPlayerContext';
|
||||||
import { SongRecommendations } from './components/SongRecommendations';
|
import { SongRecommendations } from './components/SongRecommendations';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
|
import { UserProfile } from './components/UserProfile';
|
||||||
|
|
||||||
type TimeOfDay = 'morning' | 'afternoon' | 'evening';
|
type TimeOfDay = 'morning' | 'afternoon' | 'evening';
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ function MusicPageContent() {
|
|||||||
const [favoriteAlbums, setFavoriteAlbums] = useState<Album[]>([]);
|
const [favoriteAlbums, setFavoriteAlbums] = useState<Album[]>([]);
|
||||||
const [favoritesLoading, setFavoritesLoading] = useState(true);
|
const [favoritesLoading, setFavoritesLoading] = useState(true);
|
||||||
const [shortcutProcessed, setShortcutProcessed] = useState(false);
|
const [shortcutProcessed, setShortcutProcessed] = useState(false);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (albums.length > 0) {
|
if (albums.length > 0) {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export default function PlaylistPage() {
|
|||||||
artist: song.artist,
|
artist: song.artist,
|
||||||
album: song.album,
|
album: song.album,
|
||||||
duration: song.duration,
|
duration: song.duration,
|
||||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined,
|
||||||
albumId: song.albumId,
|
albumId: song.albumId,
|
||||||
artistId: song.artistId,
|
artistId: song.artistId,
|
||||||
starred: !!song.starred
|
starred: !!song.starred
|
||||||
@@ -77,7 +77,7 @@ export default function PlaylistPage() {
|
|||||||
artist: song.artist,
|
artist: song.artist,
|
||||||
album: song.album,
|
album: song.album,
|
||||||
duration: song.duration,
|
duration: song.duration,
|
||||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined,
|
||||||
albumId: song.albumId,
|
albumId: song.albumId,
|
||||||
artistId: song.artistId,
|
artistId: song.artistId,
|
||||||
starred: !!song.starred
|
starred: !!song.starred
|
||||||
@@ -98,7 +98,7 @@ export default function PlaylistPage() {
|
|||||||
artist: song.artist,
|
artist: song.artist,
|
||||||
album: song.album,
|
album: song.album,
|
||||||
duration: song.duration,
|
duration: song.duration,
|
||||||
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
|
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined,
|
||||||
albumId: song.albumId,
|
albumId: song.albumId,
|
||||||
artistId: song.artistId,
|
artistId: song.artistId,
|
||||||
starred: !!song.starred
|
starred: !!song.starred
|
||||||
@@ -209,7 +209,7 @@ export default function PlaylistPage() {
|
|||||||
|
|
||||||
{/* Album Art */}
|
{/* Album Art */}
|
||||||
<div className="w-12 h-12 mr-4 shrink-0"> <Image
|
<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}
|
alt={song.album}
|
||||||
width={48}
|
width={48}
|
||||||
height={48}
|
height={48}
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ const SettingsPage = () => {
|
|||||||
style={{ columnFill: 'balance' }}>
|
style={{ columnFill: 'balance' }}>
|
||||||
|
|
||||||
{!hasEnvConfig && (
|
{!hasEnvConfig && (
|
||||||
<Card className="mb-6 break-inside-avoid">
|
<Card className="mb-6 break-inside-avoid py-5">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<FaServer className="w-5 h-5" />
|
<FaServer className="w-5 h-5" />
|
||||||
@@ -442,7 +442,7 @@ const SettingsPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hasEnvConfig && (
|
{hasEnvConfig && (
|
||||||
<Card className="mb-6 break-inside-avoid">
|
<Card className="mb-6 break-inside-avoid py-5">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<FaServer className="w-5 h-5" />
|
<FaServer className="w-5 h-5" />
|
||||||
@@ -469,7 +469,7 @@ const SettingsPage = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card className="mb-6 break-inside-avoid">
|
<Card className="mb-6 break-inside-avoid py-5">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<FaLastfm className="w-5 h-5" />
|
<FaLastfm className="w-5 h-5" />
|
||||||
@@ -547,7 +547,7 @@ const SettingsPage = () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card> */}
|
</Card> */}
|
||||||
|
|
||||||
<Card className="mb-6 break-inside-avoid">
|
<Card className="mb-6 break-inside-avoid py-5">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Settings className="w-5 h-5" />
|
<Settings className="w-5 h-5" />
|
||||||
@@ -602,7 +602,7 @@ const SettingsPage = () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="mb-6 break-inside-avoid">
|
{/* <Card className="mb-6 break-inside-avoid py-5">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<FaLastfm className="w-5 h-5" />
|
<FaLastfm className="w-5 h-5" />
|
||||||
@@ -695,7 +695,7 @@ const SettingsPage = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card> */}
|
||||||
|
|
||||||
{/* Sidebar Customization */}
|
{/* Sidebar Customization */}
|
||||||
<div className="break-inside-avoid mb-6">
|
<div className="break-inside-avoid mb-6">
|
||||||
@@ -712,7 +712,7 @@ const SettingsPage = () => {
|
|||||||
<CacheManagement />
|
<CacheManagement />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="mb-6 break-inside-avoid">
|
<Card className="mb-6 break-inside-avoid py-5">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Appearance</CardTitle>
|
<CardTitle>Appearance</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -761,7 +761,7 @@ const SettingsPage = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Theme Preview */}
|
{/* Theme Preview */}
|
||||||
<Card className="mb-6 break-inside-avoid">
|
<Card className="mb-6 break-inside-avoid py-5">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Preview</CardTitle>
|
<CardTitle>Preview</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -789,6 +789,47 @@ const SettingsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -15,6 +15,3 @@ printenv | grep NEXT_PUBLIC_ | while read -r line ; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
echo "✅ Environment variable replacement complete"
|
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[];
|
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 {
|
class NavidromeAPI {
|
||||||
private config: NavidromeConfig;
|
private config: NavidromeConfig;
|
||||||
private clientName = 'miceclient';
|
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[]> {
|
async getArtists(): Promise<Artist[]> {
|
||||||
const response = await this.makeRequest('getArtists');
|
const response = await this.makeRequest('getArtists');
|
||||||
const artists: Artist[] = [];
|
const artists: Artist[] = [];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
images: {
|
images: {
|
||||||
|
qualities: [50, 75, 100],
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
protocol: "https",
|
protocol: "https",
|
||||||
|
|||||||
15
package.json
@@ -52,7 +52,7 @@
|
|||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"next": "15.3.4",
|
"next": "15.4.4",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"posthog-js": "^1.255.0",
|
"posthog-js": "^1.255.0",
|
||||||
"posthog-node": "^5.1.1",
|
"posthog-node": "^5.1.1",
|
||||||
@@ -75,15 +75,26 @@
|
|||||||
"@types/react": "19.1.8",
|
"@types/react": "19.1.8",
|
||||||
"@types/react-dom": "19.1.6",
|
"@types/react-dom": "19.1.6",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
|
"eslint": "^9.31",
|
||||||
"eslint": "^9.32",
|
"eslint": "^9.32",
|
||||||
"eslint-config-next": "15.4.4",
|
"eslint-config-next": "15.4.4",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.12.4",
|
"packageManager": "pnpm@10.13.1",
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@types/react": "19.1.8",
|
"@types/react": "19.1.8",
|
||||||
"@types/react-dom": "19.1.6"
|
"@types/react-dom": "19.1.6"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"@types/react": "19.1.8",
|
||||||
|
"@types/react-dom": "19.1.6"
|
||||||
|
},
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"sharp",
|
||||||
|
"unrs-resolver"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
580
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 |