Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00ce124a3e | |||
| 7262305343 | |||
| 8009609825 | |||
| b6342ded21 | |||
| 09de406890 | |||
| 477b172c6c | |||
| ed41ad6671 | |||
| b59deee486 | |||
| f1957c7d91 | |||
| 88c31c5082 | |||
| 4721c058ae | |||
| e5bd7209eb | |||
| d8b03ec702 | |||
| ae288cc4e4 | |||
| 666722056b | |||
| 06aaa8cf74 | |||
| 394bdaca89 | |||
| 5dda540a16 | |||
| 7a146e9e4f | |||
| 7ac5eb89ce | |||
|
|
4652689aec | ||
|
|
a3dcfc043d | ||
|
|
efb4e5aef5 | ||
|
|
b9e75622d1 | ||
|
|
b82ba0749b | ||
| 8886302809 | |||
|
|
b5669cf831 |
@@ -1 +1 @@
|
||||
NEXT_PUBLIC_COMMIT_SHA=b5fc053
|
||||
NEXT_PUBLIC_COMMIT_SHA=477b172
|
||||
|
||||
3
.github/workflows/github-release.yml
vendored
3
.github/workflows/github-release.yml
vendored
@@ -3,7 +3,8 @@ name: GitHub Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- '[0-9][0-9][0-9][0-9].[0-9][0-9].[0-9][0-9]'
|
||||
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
7
.github/workflows/nightly.yml
vendored
7
.github/workflows/nightly.yml
vendored
@@ -1,13 +1,9 @@
|
||||
name: Development Docker Image (Nightly)
|
||||
name: Docker Image (Nightly)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run every night at 5:00 UTC
|
||||
- cron: '0 5 * * *'
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
env:
|
||||
REGISTRY: docker.io
|
||||
@@ -89,7 +85,6 @@ jobs:
|
||||
tags: |
|
||||
type=raw,value=nightly
|
||||
type=raw,value=dev-latest
|
||||
type=sha,prefix=dev-
|
||||
labels: |
|
||||
org.opencontainers.image.created=${{ github.event.head_commit.timestamp }}
|
||||
org.opencontainers.image.licenses=MIT
|
||||
|
||||
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
@@ -68,7 +68,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
tags: ${{ steps.meta.outputs.tags || 'latest' }}
|
||||
labels: |
|
||||
${{ steps.meta.outputs.labels }}
|
||||
org.opencontainers.image.description=$(cat README.md | head -20 | tr '\n' ' ')
|
||||
@@ -81,10 +81,3 @@ jobs:
|
||||
cache-to: |
|
||||
type=gha,mode=max,scope=deps-only
|
||||
|
||||
|
||||
# - name: Docker Hub Description
|
||||
# uses: peter-evans/dockerhub-description@v4
|
||||
# with:
|
||||
# username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
# password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
# repository: sillyangel/mice
|
||||
|
||||
@@ -18,7 +18,7 @@ This is a "Modern" Navidrome (or Subsonic) client built with [Next.js](https://n
|
||||
- **Search** - Find music across your entire library
|
||||
- **Audio Player** with queue management
|
||||
- **Scrobbling** - Track your listening history
|
||||
- **Playlist Management** - Create and manage playlists
|
||||
<!-- - **Playlist Management** - Create and manage playlists -->
|
||||
|
||||
### Preview
|
||||

|
||||
@@ -34,8 +34,8 @@ This is a "Modern" Navidrome (or Subsonic) client built with [Next.js](https://n
|
||||
1. **Clone and install the required dependencies**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sillyangel/project-still.git
|
||||
cd project-still/
|
||||
git clone https://github.com/sillyangel/mice.git
|
||||
cd mice/
|
||||
pnpm install
|
||||
|
||||
# or npm
|
||||
@@ -113,7 +113,7 @@ docker run -p 3000:3000 \
|
||||
sillyangel/mice:latest
|
||||
```
|
||||
|
||||
**For detailed Docker configuration, environment variables, troubleshooting, and advanced setups, see [DOCKER.md](./DOCKER.md)**
|
||||
**For detailed Docker configuration, environment variables, troubleshooting, and advanced setups, see [DOCKER.md](./docs/DOCKER.md)**
|
||||
|
||||
## Tech Stack
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useParams } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import { Album, Song } from '@/lib/navidrome';
|
||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||
import { Play, Heart } from 'lucide-react';
|
||||
import { Play, Heart, Shuffle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Link from 'next/link';
|
||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext'
|
||||
@@ -111,6 +111,19 @@ export default function AlbumPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleShuffleAlbum = async (): Promise<void> => {
|
||||
if (!album || !tracklist.length) return;
|
||||
|
||||
try {
|
||||
// Shuffle the tracklist
|
||||
const shuffled = [...tracklist].sort(() => Math.random() - 0.5);
|
||||
// Play the first shuffled track
|
||||
await playAlbumFromTrack(album.id, shuffled[0].id);
|
||||
} catch (error) {
|
||||
console.error('Failed to shuffle album:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const isCurrentlyPlaying = (song: Song): boolean => {
|
||||
return currentTrack?.id === song.id;
|
||||
};
|
||||
@@ -124,13 +137,13 @@ export default function AlbumPage() {
|
||||
// Dynamic cover art URLs based on image size
|
||||
const getMobileCoverArtUrl = () => {
|
||||
return album.coverArt && api
|
||||
? api.getCoverArtUrl(album.coverArt, 600)
|
||||
? api.getCoverArtUrl(album.coverArt, 300)
|
||||
: '/default-user.jpg';
|
||||
};
|
||||
|
||||
const getDesktopCoverArtUrl = () => {
|
||||
return album.coverArt && api
|
||||
? api.getCoverArtUrl(album.coverArt, 600)
|
||||
? api.getCoverArtUrl(album.coverArt, 300)
|
||||
: '/default-user.jpg';
|
||||
};
|
||||
|
||||
@@ -146,8 +159,8 @@ export default function AlbumPage() {
|
||||
<Image
|
||||
src={getMobileCoverArtUrl()}
|
||||
alt={album.name}
|
||||
width={600}
|
||||
height={600}
|
||||
width={300}
|
||||
height={300}
|
||||
className="rounded-md shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
@@ -173,6 +186,14 @@ export default function AlbumPage() {
|
||||
>
|
||||
<Play className="w-6 h-6" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-12 h-12 rounded-full p-0"
|
||||
onClick={handleShuffleAlbum}
|
||||
title="Shuffle Album"
|
||||
>
|
||||
<Shuffle className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -182,8 +203,8 @@ export default function AlbumPage() {
|
||||
<Image
|
||||
src={getDesktopCoverArtUrl()}
|
||||
alt={album.name}
|
||||
width={600}
|
||||
height={600}
|
||||
width={300}
|
||||
height={300}
|
||||
className="rounded-md"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
@@ -200,8 +221,13 @@ export default function AlbumPage() {
|
||||
{/* Controls row */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button className="px-5" onClick={() => playAlbum(album.id)}>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Play
|
||||
</Button>
|
||||
<Button variant="outline" className="px-5" onClick={handleShuffleAlbum}>
|
||||
<Shuffle className="w-4 h-4 mr-2" />
|
||||
Shuffle
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Album info */}
|
||||
|
||||
@@ -388,6 +388,11 @@ export const AudioPlayer: React.FC = () => {
|
||||
|
||||
// Auto-play only if the track has the autoPlay flag and audio is initialized
|
||||
if (currentTrack.autoPlay && (!isMobile || audioInitialized)) {
|
||||
// Start crossfade fade-in if enabled
|
||||
if (audioSettings.crossfadeDuration > 0 && audioEffects) {
|
||||
audioEffects.startCrossfade();
|
||||
}
|
||||
|
||||
// Add a small delay for iOS compatibility
|
||||
const playPromise = isMobile ?
|
||||
new Promise(resolve => setTimeout(resolve, 100)).then(() => audioCurrent.play()) :
|
||||
@@ -410,7 +415,7 @@ export const AudioPlayer: React.FC = () => {
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}
|
||||
}, [currentTrack, onTrackStart, onTrackPlay, isMobile, audioInitialized, audioEffects, audioSettings.gaplessPlayback, audioSettings.replayGainEnabled, queue]);
|
||||
}, [currentTrack, onTrackStart, onTrackPlay, isMobile, audioInitialized, audioEffects, audioSettings.gaplessPlayback, audioSettings.replayGainEnabled, audioSettings.crossfadeDuration, queue]);
|
||||
|
||||
useEffect(() => {
|
||||
const audioCurrent = audioRef.current;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useNavidromeConfig } from '@/app/components/NavidromeConfigContext';
|
||||
import { useTheme } from '@/app/components/ThemeProvider';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaPalette, FaLastfm } from 'react-icons/fa';
|
||||
import type { SidebarItem, SidebarLayoutSettings } from '@/hooks/use-sidebar-layout';
|
||||
|
||||
export function LoginForm({
|
||||
className,
|
||||
@@ -36,6 +37,7 @@ export function LoginForm({
|
||||
});
|
||||
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
// Settings for step 2
|
||||
const [scrobblingEnabled, setScrobblingEnabled] = useState(() => {
|
||||
@@ -45,7 +47,8 @@ export function LoginForm({
|
||||
return true;
|
||||
});
|
||||
|
||||
// New settings - removed sidebar and standalone lastfm options
|
||||
// Sidebar settings with new defaults
|
||||
const [sidebarShortcuts, setSidebarShortcuts] = useState<'albums' | 'playlists' | 'both'>('playlists');
|
||||
|
||||
// Check if Navidrome is configured via environment variables
|
||||
const hasEnvConfig = React.useMemo(() => {
|
||||
@@ -119,6 +122,7 @@ export function LoginForm({
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.serverUrl || !formData.username || !formData.password) {
|
||||
setHasError(true);
|
||||
toast({
|
||||
title: "Missing Information",
|
||||
description: "Please fill in all fields before proceeding.",
|
||||
@@ -128,6 +132,7 @@ export function LoginForm({
|
||||
}
|
||||
|
||||
setIsTesting(true);
|
||||
setHasError(false);
|
||||
try {
|
||||
// Strip trailing slash from server URL before testing
|
||||
const cleanServerUrl = formData.serverUrl.replace(/\/+$/, '');
|
||||
@@ -154,6 +159,7 @@ export function LoginForm({
|
||||
// Move to settings step
|
||||
setStep('settings');
|
||||
} else {
|
||||
setHasError(true);
|
||||
toast({
|
||||
title: "Connection Failed",
|
||||
description: "Could not connect to the server. Please check your settings.",
|
||||
@@ -161,6 +167,7 @@ export function LoginForm({
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setHasError(true);
|
||||
toast({
|
||||
title: "Connection Error",
|
||||
description: "An error occurred while testing the connection.",
|
||||
@@ -175,6 +182,30 @@ export function LoginForm({
|
||||
// Save all settings
|
||||
localStorage.setItem('lastfm-scrobbling-enabled', scrobblingEnabled.toString());
|
||||
|
||||
// Save sidebar settings with new defaults
|
||||
const defaultSidebarItems: SidebarItem[] = [
|
||||
{ id: 'home', label: 'Home', visible: true, icon: 'home', href: '/' },
|
||||
{ id: 'queue', label: 'Queue', visible: true, icon: 'queue', href: '/queue' },
|
||||
{ id: 'artists', label: 'Artists', visible: true, icon: 'artists', href: '/library/artists' },
|
||||
{ id: 'albums', label: 'Albums', visible: true, icon: 'albums', href: '/library/albums' },
|
||||
{ id: 'playlists', label: 'Playlists', visible: true, icon: 'playlists', href: '/library/playlists' },
|
||||
{ id: 'favorites', label: 'Favorites', visible: true, icon: 'favorites', href: '/favorites' },
|
||||
{ id: 'settings', label: 'Settings', visible: true, icon: 'settings', href: '/settings' },
|
||||
// Hidden by default
|
||||
{ id: 'search', label: 'Search', visible: false, icon: 'search', href: '/search' },
|
||||
{ id: 'radio', label: 'Radio', visible: false, icon: 'radio', href: '/radio' },
|
||||
{ id: 'browse', label: 'Browse', visible: false, icon: 'browse', href: '/browse' },
|
||||
{ id: 'songs', label: 'Songs', visible: false, icon: 'songs', href: '/library/songs' },
|
||||
{ id: 'history', label: 'History', visible: false, icon: 'history', href: '/history' },
|
||||
];
|
||||
|
||||
const sidebarSettings: SidebarLayoutSettings = {
|
||||
items: defaultSidebarItems,
|
||||
shortcuts: sidebarShortcuts,
|
||||
showIcons: true,
|
||||
};
|
||||
localStorage.setItem('sidebar-layout-settings', JSON.stringify(sidebarSettings));
|
||||
|
||||
// Mark onboarding as complete
|
||||
localStorage.setItem('onboarding-completed', '1.1.0');
|
||||
|
||||
@@ -260,6 +291,7 @@ export function LoginForm({
|
||||
<SelectValue placeholder="Select a theme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="blue">Blue</SelectItem>
|
||||
<SelectItem value="violet">Violet</SelectItem>
|
||||
<SelectItem value="red">Red</SelectItem>
|
||||
@@ -296,6 +328,24 @@ export function LoginForm({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Shortcuts */}
|
||||
<div className="grid gap-3">
|
||||
<Label>Sidebar Shortcuts</Label>
|
||||
<Select value={sidebarShortcuts} onValueChange={(value: 'albums' | 'playlists' | 'both') => setSidebarShortcuts(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="playlists">Playlists Only</SelectItem>
|
||||
<SelectItem value="albums">Albums Only</SelectItem>
|
||||
<SelectItem value="both">Both</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose what shortcuts appear in your sidebar
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button onClick={handleFinishSetup} className="w-full">
|
||||
<FaCheck className="w-4 h-4 mr-2" />
|
||||
@@ -349,6 +399,7 @@ export function LoginForm({
|
||||
placeholder="https://your-navidrome-server.com"
|
||||
value={formData.serverUrl}
|
||||
onChange={(e) => handleInputChange('serverUrl', e.target.value)}
|
||||
className={hasError ? "border-destructive focus-visible:ring-destructive" : ""}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -363,6 +414,7 @@ export function LoginForm({
|
||||
placeholder="your-username"
|
||||
value={formData.username}
|
||||
onChange={(e) => handleInputChange('username', e.target.value)}
|
||||
className={hasError ? "border-destructive focus-visible:ring-destructive" : ""}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -376,6 +428,7 @@ export function LoginForm({
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleInputChange('password', e.target.value)}
|
||||
className={hasError ? "border-destructive focus-visible:ring-destructive" : ""}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,6 @@ commit_parsers = [
|
||||
]
|
||||
protect_breaking_commits = false
|
||||
filter_commits = false
|
||||
tag_pattern = "v[0-9].*"
|
||||
tag_pattern = "[0-9][0-9][0-9][0-9].[0-9][0-9].[0-9][0-9]"
|
||||
topo_order = false
|
||||
sort_commits = "oldest"
|
||||
|
||||
@@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-100 flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
"fixed top-0 z-[9999] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -126,6 +126,7 @@ export class AudioEffects {
|
||||
public setCrossfadeTime(seconds: number) {
|
||||
if (this.crossfadeGainNode) {
|
||||
const now = this.context.currentTime;
|
||||
this.crossfadeGainNode.gain.cancelScheduledValues(now);
|
||||
this.crossfadeGainNode.gain.setValueAtTime(1, now);
|
||||
this.crossfadeGainNode.gain.linearRampToValueAtTime(0, now + seconds);
|
||||
}
|
||||
@@ -133,7 +134,10 @@ export class AudioEffects {
|
||||
|
||||
public startCrossfade() {
|
||||
if (this.crossfadeGainNode) {
|
||||
this.crossfadeGainNode.gain.value = 1;
|
||||
const now = this.context.currentTime;
|
||||
this.crossfadeGainNode.gain.cancelScheduledValues(now);
|
||||
this.crossfadeGainNode.gain.setValueAtTime(0, now);
|
||||
this.crossfadeGainNode.gain.linearRampToValueAtTime(1, now + 0.5); // Fast fade in
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user