Update AudioPlayer and FullScreenPlayer for improved mobile audio handling; refactor WhatsNewPopup for better dialog structure; clean up LoginForm by removing unused settings
This commit is contained in:
@@ -1 +1 @@
|
||||
NEXT_PUBLIC_COMMIT_SHA=25e9bd6
|
||||
NEXT_PUBLIC_COMMIT_SHA=1dfb86f
|
||||
|
||||
@@ -129,26 +129,73 @@ 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);
|
||||
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);
|
||||
@@ -172,7 +219,7 @@ export const AudioPlayer: React.FC = () => {
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach(key => localStorage.removeItem(key));
|
||||
}, [isMobile, audioInitialized]);
|
||||
}, [isMobile, audioInitialized, volume]);
|
||||
|
||||
// Apply volume to audio element when volume changes
|
||||
useEffect(() => {
|
||||
@@ -470,22 +517,57 @@ export const AudioPlayer: React.FC = () => {
|
||||
|
||||
const togglePlayPause = async () => {
|
||||
if (audioCurrent && currentTrack) {
|
||||
// Detect if running as PWA
|
||||
const isPWA = window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||||
|
||||
console.log('🎵 togglePlayPause called:', {
|
||||
isPlaying,
|
||||
isMobile,
|
||||
isPWA,
|
||||
audioInitialized,
|
||||
currentTrackUrl: currentTrack.url,
|
||||
audioSrc: audioCurrent.src,
|
||||
audioReadyState: audioCurrent.readyState
|
||||
});
|
||||
|
||||
if (isPlaying) {
|
||||
console.log('⏸️ Pausing audio');
|
||||
audioCurrent.pause();
|
||||
setIsPlaying(false);
|
||||
onTrackPause(audioCurrent.currentTime);
|
||||
} else {
|
||||
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);
|
||||
@@ -494,12 +576,14 @@ export const AudioPlayer: React.FC = () => {
|
||||
}, 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);
|
||||
@@ -511,16 +595,20 @@ export const AudioPlayer: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
console.log('▶️ Attempting to play audio...');
|
||||
await audioCurrent.play();
|
||||
setIsPlaying(true);
|
||||
setAudioInitialized(true);
|
||||
onTrackPlay(currentTrack);
|
||||
console.log('✅ Audio play successful');
|
||||
} catch (error) {
|
||||
console.error('Failed to play audio:', error);
|
||||
console.error('❌ Failed to play audio:', error);
|
||||
|
||||
// Additional mobile-specific handling
|
||||
if (isMobile) {
|
||||
try {
|
||||
console.log('🔄 Attempting mobile audio recovery...');
|
||||
|
||||
// Try creating and resuming audio context
|
||||
const AudioContextClass = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (AudioContextClass) {
|
||||
@@ -534,18 +622,22 @@ export const AudioPlayer: React.FC = () => {
|
||||
// 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);
|
||||
console.error('❌ Audio play retry failed:', retryError);
|
||||
setIsPlaying(false);
|
||||
|
||||
// Show user-friendly error on mobile
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Playback Error",
|
||||
description: "Unable to play audio. Please try again or check your connection.",
|
||||
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 {
|
||||
|
||||
@@ -148,7 +148,7 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
||||
// Only reset scroll on desktop to avoid iOS audio interference
|
||||
const shouldReset = !isMobile && showLyrics && lyrics.length > 0;
|
||||
|
||||
if (currentTrack && shouldReset && lyricsRef.current) {
|
||||
if (currentTrack?.id && shouldReset && lyricsRef.current) {
|
||||
const resetTimeout = setTimeout(() => {
|
||||
try {
|
||||
const scrollContainer = lyricsRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement;
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { title } from 'process';
|
||||
|
||||
// Current app version from package.json
|
||||
const APP_VERSION = '2025.07.31';
|
||||
@@ -209,65 +206,86 @@ export function WhatsNewPopup() {
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh]">
|
||||
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||
<div>
|
||||
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
What's New in Mice
|
||||
<Badge variant="outline">
|
||||
{tab === 'latest' ? currentVersionChangelog.version : archiveChangelog?.version}
|
||||
</Badge>
|
||||
</DialogTitle>
|
||||
<>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* Dialog content */}
|
||||
<div className="relative bg-background rounded-lg shadow-lg max-w-2xl w-full mx-4 max-h-[80vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex flex-row items-center justify-between space-y-0 p-6 pb-4 shrink-0">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
What's New in Mice
|
||||
<Badge variant="outline">
|
||||
{tab === 'latest' ? currentVersionChangelog.version : archiveChangelog?.version}
|
||||
</Badge>
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 px-6 pt-4 shrink-0">
|
||||
<Button
|
||||
variant={tab === 'latest' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTab('latest')}
|
||||
>
|
||||
Latest
|
||||
</Button>
|
||||
<Button
|
||||
variant={tab === 'archive' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTab('archive')}
|
||||
disabled={archiveChangelogs.length === 0}
|
||||
>
|
||||
Archive
|
||||
</Button>
|
||||
{tab === 'archive' && archiveChangelogs.length > 0 && (
|
||||
<select
|
||||
className="ml-2 border rounded px-2 py-1 text-sm bg-background"
|
||||
value={selectedArchive}
|
||||
onChange={e => setSelectedArchive(e.target.value)}
|
||||
>
|
||||
{archiveChangelogs.map(entry => (
|
||||
<option key={entry.version} value={entry.version}>
|
||||
{entry.version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
|
||||
<div className="space-y-6">
|
||||
{tab === 'latest'
|
||||
? renderChangelog(currentVersionChangelog)
|
||||
: archiveChangelog && renderChangelog(archiveChangelog)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer button */}
|
||||
<div className="flex justify-center p-6 pt-4 shrink-0">
|
||||
<Button onClick={handleClose}>
|
||||
Got it!
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Tabs */}
|
||||
<>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Button
|
||||
variant={tab === 'latest' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTab('latest')}
|
||||
>
|
||||
Latest
|
||||
</Button>
|
||||
<Button
|
||||
variant={tab === 'archive' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTab('archive')}
|
||||
disabled={archiveChangelogs.length === 0}
|
||||
>
|
||||
Archive
|
||||
</Button>
|
||||
{tab === 'archive' && archiveChangelogs.length > 0 && (
|
||||
<select
|
||||
className="ml-2 border rounded px-2 py-1 text-sm"
|
||||
value={selectedArchive}
|
||||
onChange={e => setSelectedArchive(e.target.value)}
|
||||
>
|
||||
{archiveChangelogs.map(entry => (
|
||||
<option key={entry.version} value={entry.version}>
|
||||
{entry.version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea className="max-h-[60vh] pr-4">
|
||||
{tab === 'latest'
|
||||
? renderChangelog(currentVersionChangelog)
|
||||
: archiveChangelog && renderChangelog(archiveChangelog)}
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button onClick={handleClose}>
|
||||
Got it!
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { useNavidromeConfig } from '@/app/components/NavidromeConfigContext';
|
||||
import { useTheme } from '@/app/components/ThemeProvider';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaPalette, FaLastfm, FaBars } from 'react-icons/fa';
|
||||
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaPalette, FaLastfm } from 'react-icons/fa';
|
||||
|
||||
export function LoginForm({
|
||||
className,
|
||||
@@ -45,20 +45,7 @@ export function LoginForm({
|
||||
return true;
|
||||
});
|
||||
|
||||
// New settings
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('sidebar-collapsed') === 'true';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const [standaloneLastfmEnabled, setStandaloneLastfmEnabled] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('standalone-lastfm-enabled') === 'true';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
// New settings - removed sidebar and standalone lastfm options
|
||||
|
||||
// Check if Navidrome is configured via environment variables
|
||||
const hasEnvConfig = React.useMemo(() => {
|
||||
@@ -187,8 +174,6 @@ export function LoginForm({
|
||||
const handleFinishSetup = () => {
|
||||
// Save all settings
|
||||
localStorage.setItem('lastfm-scrobbling-enabled', scrobblingEnabled.toString());
|
||||
localStorage.setItem('sidebar-collapsed', sidebarCollapsed.toString());
|
||||
localStorage.setItem('standalone-lastfm-enabled', standaloneLastfmEnabled.toString());
|
||||
|
||||
// Mark onboarding as complete
|
||||
localStorage.setItem('onboarding-completed', '1.1.0');
|
||||
@@ -252,7 +237,7 @@ export function LoginForm({
|
||||
if (step === 'settings') {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card>
|
||||
<Card className='py-5'>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FaPalette className="w-5 h-5" />
|
||||
@@ -286,29 +271,6 @@ export function LoginForm({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Settings */}
|
||||
<div className="grid gap-3">
|
||||
<Label className="flex items-center gap-2">
|
||||
<FaBars className="w-4 h-4" />
|
||||
Sidebar Layout
|
||||
</Label>
|
||||
<Select
|
||||
value={sidebarCollapsed ? "collapsed" : "expanded"}
|
||||
onValueChange={(value) => setSidebarCollapsed(value === "collapsed")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="expanded">Expanded (with labels)</SelectItem>
|
||||
<SelectItem value="collapsed">Collapsed (icons only)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You can always toggle this later using the button in the sidebar
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Last.fm Scrobbling */}
|
||||
<div className="grid gap-3">
|
||||
<Label className="flex items-center gap-2">
|
||||
@@ -334,31 +296,6 @@ export function LoginForm({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Standalone Last.fm */}
|
||||
<div className="grid gap-3">
|
||||
<Label className="flex items-center gap-2">
|
||||
<FaLastfm className="w-4 h-4" />
|
||||
Standalone Last.fm (Advanced)
|
||||
</Label>
|
||||
<Select
|
||||
value={standaloneLastfmEnabled ? "enabled" : "disabled"}
|
||||
onValueChange={(value) => setStandaloneLastfmEnabled(value === "enabled")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="enabled">Enabled</SelectItem>
|
||||
<SelectItem value="disabled">Disabled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{standaloneLastfmEnabled
|
||||
? "Direct Last.fm API integration (configure in Settings later)"
|
||||
: "Use only Navidrome's Last.fm integration"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button onClick={handleFinishSetup} className="w-full">
|
||||
<FaCheck className="w-4 h-4 mr-2" />
|
||||
@@ -383,7 +320,7 @@ export function LoginForm({
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card>
|
||||
<Card className="py-5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FaServer className="w-5 h-5" />
|
||||
|
||||
Reference in New Issue
Block a user