Compare commits
28
Commits
@@ -1,5 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/painting.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../models/player.dart';
|
import '../models/player.dart';
|
||||||
import '../services/music_assistant_api.dart';
|
import '../services/music_assistant_api.dart';
|
||||||
@@ -10,6 +12,7 @@ import '../services/auth/auth_manager.dart';
|
|||||||
import '../services/device_id_service.dart';
|
import '../services/device_id_service.dart';
|
||||||
import '../services/cache_service.dart';
|
import '../services/cache_service.dart';
|
||||||
import '../services/local_player_service.dart';
|
import '../services/local_player_service.dart';
|
||||||
|
import '../services/metadata_service.dart';
|
||||||
import '../constants/timings.dart';
|
import '../constants/timings.dart';
|
||||||
import '../main.dart' show audioHandler;
|
import '../main.dart' show audioHandler;
|
||||||
|
|
||||||
@@ -172,7 +175,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
} else if (state == MAConnectionState.disconnected) {
|
} else if (state == MAConnectionState.disconnected) {
|
||||||
_availablePlayers = [];
|
_availablePlayers = [];
|
||||||
_selectedPlayer = null;
|
_selectedPlayer = null;
|
||||||
_cacheService.clearAll();
|
// Only clear detail caches on disconnect, NOT home screen caches
|
||||||
|
// Home screen data should persist across reconnects for better UX
|
||||||
|
_cacheService.clearAllDetailCaches();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) {
|
onError: (error) {
|
||||||
@@ -1092,7 +1097,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
selectPlayer(playerToSelect);
|
selectPlayer(playerToSelect);
|
||||||
}
|
}
|
||||||
|
|
||||||
_preloadAdjacentPlayers(preloadAll: true);
|
// Await preload so track data and images are ready for swipe gestures
|
||||||
|
await _preloadAdjacentPlayers(preloadAll: true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ErrorHandler.logError('Load and select players', e);
|
ErrorHandler.logError('Load and select players', e);
|
||||||
}
|
}
|
||||||
@@ -1103,6 +1109,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
|
|
||||||
SettingsService.setLastSelectedPlayerId(player.playerId);
|
SettingsService.setLastSelectedPlayerId(player.playerId);
|
||||||
|
|
||||||
|
// Immediately set currentTrack from cache to avoid flash during player switch
|
||||||
|
// This ensures the UI shows the correct track info immediately, before
|
||||||
|
// the async _updatePlayerState() completes.
|
||||||
|
// IMPORTANT: Always set from cache, even if null - this prevents showing
|
||||||
|
// stale track info when switching to a non-playing player.
|
||||||
|
_currentTrack = _cacheService.getCachedTrackForPlayer(player.playerId);
|
||||||
|
|
||||||
_startPlayerStatePolling();
|
_startPlayerStatePolling();
|
||||||
|
|
||||||
_preloadAdjacentPlayers();
|
_preloadAdjacentPlayers();
|
||||||
@@ -1140,9 +1153,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
if (prevIndex != currentIndex) playersToPreload.add(players[prevIndex]);
|
if (prevIndex != currentIndex) playersToPreload.add(players[prevIndex]);
|
||||||
if (nextIndex != currentIndex) playersToPreload.add(players[nextIndex]);
|
if (nextIndex != currentIndex) playersToPreload.add(players[nextIndex]);
|
||||||
|
|
||||||
for (final player in playersToPreload) {
|
// Await all preloads so images are ready for next swipe
|
||||||
_preloadPlayerTrack(player);
|
await Future.wait(
|
||||||
}
|
playersToPreload.map((player) => _preloadPlayerTrack(player)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _preloadPlayerTrack(Player player) async {
|
Future<void> _preloadPlayerTrack(Player player) async {
|
||||||
@@ -1172,6 +1186,12 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
} else {
|
} else {
|
||||||
_cacheService.setCachedTrackForPlayer(player.playerId, track);
|
_cacheService.setCachedTrackForPlayer(player.playerId, track);
|
||||||
_logger.log('🔍 Preload ${player.name}: CACHED track "${track.name}"');
|
_logger.log('🔍 Preload ${player.name}: CACHED track "${track.name}"');
|
||||||
|
|
||||||
|
// Also precache the image so it's ready for swipe preview
|
||||||
|
final imageUrl = getImageUrl(track, size: 512);
|
||||||
|
if (imageUrl != null) {
|
||||||
|
_precacheImage(imageUrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_logger.log('🔍 Preload ${player.name}: NO TRACK - queue empty');
|
_logger.log('🔍 Preload ${player.name}: NO TRACK - queue empty');
|
||||||
@@ -1185,6 +1205,34 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Precache an image URL so it loads instantly when displayed
|
||||||
|
Future<void> _precacheImage(String url) async {
|
||||||
|
try {
|
||||||
|
final imageProvider = CachedNetworkImageProvider(url);
|
||||||
|
final imageStream = imageProvider.resolve(const ImageConfiguration());
|
||||||
|
final completer = Completer<void>();
|
||||||
|
late ImageStreamListener listener;
|
||||||
|
|
||||||
|
listener = ImageStreamListener(
|
||||||
|
(ImageInfo info, bool synchronousCall) {
|
||||||
|
if (!completer.isCompleted) completer.complete();
|
||||||
|
imageStream.removeListener(listener);
|
||||||
|
},
|
||||||
|
onError: (exception, stackTrace) {
|
||||||
|
if (!completer.isCompleted) completer.completeError(exception);
|
||||||
|
imageStream.removeListener(listener);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
imageStream.addListener(listener);
|
||||||
|
await completer.future.timeout(const Duration(seconds: 5), onTimeout: () {
|
||||||
|
imageStream.removeListener(listener);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Silently ignore precache errors - image will load on demand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> preloadAllPlayerTracks() async {
|
Future<void> preloadAllPlayerTracks() async {
|
||||||
if (_api == null) return;
|
if (_api == null) return;
|
||||||
|
|
||||||
@@ -1453,6 +1501,19 @@ class MusicAssistantProvider with ChangeNotifier {
|
|||||||
return _api?.getImageUrl(item, size: size);
|
return _api?.getImageUrl(item, size: size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get artist image URL with fallback to external sources (Deezer, Fanart.tv)
|
||||||
|
/// Returns a Future since fallback requires async API calls
|
||||||
|
Future<String?> getArtistImageUrlWithFallback(Artist artist, {int size = 256}) async {
|
||||||
|
// Try Music Assistant first
|
||||||
|
final maUrl = _api?.getImageUrl(artist, size: size);
|
||||||
|
if (maUrl != null) {
|
||||||
|
return maUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to external sources (Deezer, Fanart.tv, etc.)
|
||||||
|
return MetadataService.getArtistImageUrl(artist.name);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// PLAYER AND QUEUE MANAGEMENT
|
// PLAYER AND QUEUE MANAGEMENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -74,14 +74,55 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
if (maProvider.api == null) return;
|
if (maProvider.api == null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Toggle the state locally first
|
|
||||||
final newState = !_isFavorite;
|
final newState = !_isFavorite;
|
||||||
|
|
||||||
await maProvider.api!.toggleFavorite(
|
if (newState) {
|
||||||
'album',
|
// For adding: use the actual provider and itemId from provider_mappings
|
||||||
widget.album.itemId,
|
String actualProvider = widget.album.provider;
|
||||||
widget.album.provider,
|
String actualItemId = widget.album.itemId;
|
||||||
);
|
|
||||||
|
if (widget.album.providerMappings != null && widget.album.providerMappings!.isNotEmpty) {
|
||||||
|
// Find a non-library provider mapping (e.g., spotify, qobuz, etc.)
|
||||||
|
final mapping = widget.album.providerMappings!.firstWhere(
|
||||||
|
(m) => m.available && m.providerInstance != 'library',
|
||||||
|
orElse: () => widget.album.providerMappings!.firstWhere(
|
||||||
|
(m) => m.available,
|
||||||
|
orElse: () => widget.album.providerMappings!.first,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
actualProvider = mapping.providerInstance;
|
||||||
|
actualItemId = mapping.itemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('Adding to favorites: provider=$actualProvider, itemId=$actualItemId');
|
||||||
|
await maProvider.api!.addToFavorites('album', actualItemId, actualProvider);
|
||||||
|
} else {
|
||||||
|
// For removing: need the library_item_id (numeric)
|
||||||
|
// When provider is "library", itemId is the library ID as a string
|
||||||
|
// Otherwise, we need to find it from provider_mappings
|
||||||
|
int? libraryItemId;
|
||||||
|
|
||||||
|
if (widget.album.provider == 'library') {
|
||||||
|
libraryItemId = int.tryParse(widget.album.itemId);
|
||||||
|
} else if (widget.album.providerMappings != null) {
|
||||||
|
// Find the library mapping
|
||||||
|
final libraryMapping = widget.album.providerMappings!.firstWhere(
|
||||||
|
(m) => m.providerInstance == 'library',
|
||||||
|
orElse: () => widget.album.providerMappings!.first,
|
||||||
|
);
|
||||||
|
if (libraryMapping.providerInstance == 'library') {
|
||||||
|
libraryItemId = int.tryParse(libraryMapping.itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (libraryItemId == null) {
|
||||||
|
_logger.log('Error: Could not determine library_item_id for removal');
|
||||||
|
throw Exception('Could not determine library ID for this album');
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('Removing from favorites: libraryItemId=$libraryItemId');
|
||||||
|
await maProvider.api!.removeFromFavorites('album', libraryItemId);
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isFavorite = newState;
|
_isFavorite = newState;
|
||||||
@@ -99,6 +140,14 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Error toggling favorite: $e');
|
_logger.log('Error toggling favorite: $e');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to update favorite: $e'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,34 +214,84 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _addAlbumToQueue() async {
|
void _addAlbumToQueue() {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final players = maProvider.availablePlayers;
|
||||||
|
|
||||||
try {
|
// Slide mini player down out of the way
|
||||||
final player = maProvider.selectedPlayer;
|
GlobalPlayerOverlay.hidePlayer();
|
||||||
if (player == null) {
|
|
||||||
_showError('No player selected');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.log('Adding album to queue on ${player.name}');
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
// Add all tracks to queue without clearing
|
backgroundColor: Colors.transparent,
|
||||||
await maProvider.playTracks(player.playerId, _tracks, startIndex: 0, clearQueue: false);
|
builder: (context) => Container(
|
||||||
_logger.log('Album added to queue on ${player.name}');
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
if (mounted) {
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
),
|
||||||
const SnackBar(
|
child: Column(
|
||||||
content: Text('Album added to queue'),
|
mainAxisSize: MainAxisSize.min,
|
||||||
duration: Duration(seconds: 1),
|
children: [
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
);
|
Text(
|
||||||
}
|
'Add album to queue on...',
|
||||||
} catch (e) {
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
_logger.log('Error adding album to queue: $e');
|
),
|
||||||
_showError('Failed to add album to queue: $e');
|
const SizedBox(height: 16),
|
||||||
}
|
if (players.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(32.0),
|
||||||
|
child: Text('No players available'),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Flexible(
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: players.length,
|
||||||
|
itemBuilder: (context, playerIndex) {
|
||||||
|
final player = players[playerIndex];
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
Icons.speaker,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
title: Text(player.name),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
try {
|
||||||
|
_logger.log('Adding album to queue on ${player.name}');
|
||||||
|
await maProvider.playTracks(
|
||||||
|
player.playerId,
|
||||||
|
_tracks,
|
||||||
|
startIndex: 0,
|
||||||
|
clearQueue: false,
|
||||||
|
);
|
||||||
|
_logger.log('Album added to queue on ${player.name}');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Album added to queue'),
|
||||||
|
duration: Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error adding album to queue: $e');
|
||||||
|
_showError('Failed to add album to queue: $e');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: MediaQuery.of(context).padding.bottom + 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).whenComplete(() {
|
||||||
|
// Show mini player again when sheet closes
|
||||||
|
GlobalPlayerOverlay.showPlayer();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _addTrackToQueue(BuildContext context, int index) {
|
void _addTrackToQueue(BuildContext context, int index) {
|
||||||
@@ -523,18 +622,22 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
// Favorite Button
|
// Favorite Button
|
||||||
Container(
|
SizedBox(
|
||||||
height: 50,
|
height: 50,
|
||||||
width: 50,
|
width: 50,
|
||||||
decoration: BoxDecoration(
|
child: FilledButton.tonal(
|
||||||
color: _isFavorite ? colorScheme.errorContainer : colorScheme.surfaceVariant,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: IconButton(
|
|
||||||
onPressed: _toggleFavorite,
|
onPressed: _toggleFavorite,
|
||||||
icon: Icon(
|
style: FilledButton.styleFrom(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(25), // Circular
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
_isFavorite ? Icons.favorite : Icons.favorite_border,
|
_isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||||
color: _isFavorite ? colorScheme.error : colorScheme.onSurfaceVariant,
|
color: _isFavorite
|
||||||
|
? colorScheme.error
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
ColorScheme? _darkColorScheme;
|
ColorScheme? _darkColorScheme;
|
||||||
bool _isDescriptionExpanded = false;
|
bool _isDescriptionExpanded = false;
|
||||||
String? _artistDescription;
|
String? _artistDescription;
|
||||||
|
String? _artistImageUrl;
|
||||||
|
|
||||||
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||||
|
|
||||||
@@ -42,16 +43,26 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadArtistAlbums();
|
_loadArtistAlbums();
|
||||||
_extractColors();
|
_loadArtistImage();
|
||||||
_loadArtistDescription();
|
_loadArtistDescription();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _extractColors() async {
|
Future<void> _loadArtistImage() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final imageUrl = maProvider.getImageUrl(widget.artist, size: 512);
|
|
||||||
|
|
||||||
if (imageUrl == null) return;
|
// Get image URL with fallback to Deezer/Fanart.tv
|
||||||
|
final imageUrl = await maProvider.getArtistImageUrlWithFallback(widget.artist, size: 512);
|
||||||
|
|
||||||
|
if (mounted && imageUrl != null) {
|
||||||
|
setState(() {
|
||||||
|
_artistImageUrl = imageUrl;
|
||||||
|
});
|
||||||
|
// Extract colors after we have the image
|
||||||
|
_extractColors(imageUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _extractColors(String imageUrl) async {
|
||||||
try {
|
try {
|
||||||
final colorSchemes = await PaletteHelper.extractColorSchemes(
|
final colorSchemes = await PaletteHelper.extractColorSchemes(
|
||||||
CachedNetworkImageProvider(imageUrl),
|
CachedNetworkImageProvider(imageUrl),
|
||||||
@@ -104,7 +115,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maProvider = context.watch<MusicAssistantProvider>();
|
final maProvider = context.watch<MusicAssistantProvider>();
|
||||||
final themeProvider = context.watch<ThemeProvider>();
|
final themeProvider = context.watch<ThemeProvider>();
|
||||||
final imageUrl = maProvider.getImageUrl(widget.artist, size: 512);
|
// Use the loaded image URL (with fallback) instead of directly from MA
|
||||||
|
final imageUrl = _artistImageUrl;
|
||||||
|
|
||||||
final useAdaptiveTheme = themeProvider.adaptiveTheme;
|
final useAdaptiveTheme = themeProvider.adaptiveTheme;
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../utils/page_transitions.dart';
|
import '../utils/page_transitions.dart';
|
||||||
import '../widgets/common/empty_state.dart';
|
import '../widgets/common/empty_state.dart';
|
||||||
|
import '../widgets/artist_avatar.dart';
|
||||||
import 'artist_details_screen.dart';
|
import 'artist_details_screen.dart';
|
||||||
|
|
||||||
class LibraryArtistsScreen extends StatelessWidget {
|
class LibraryArtistsScreen extends StatelessWidget {
|
||||||
@@ -88,8 +88,6 @@ class LibraryArtistsScreen extends StatelessWidget {
|
|||||||
Artist artist, {
|
Artist artist, {
|
||||||
Key? key,
|
Key? key,
|
||||||
}) {
|
}) {
|
||||||
final provider = context.read<MusicAssistantProvider>();
|
|
||||||
final imageUrl = provider.getImageUrl(artist, size: 128);
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
@@ -97,14 +95,11 @@ class LibraryArtistsScreen extends StatelessWidget {
|
|||||||
return RepaintBoundary(
|
return RepaintBoundary(
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
key: key,
|
key: key,
|
||||||
leading: CircleAvatar(
|
leading: ArtistAvatar(
|
||||||
radius: 24,
|
artist: artist,
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
radius: 24,
|
||||||
backgroundImage: imageUrl != null ? CachedNetworkImageProvider(imageUrl) : null,
|
imageSize: 128,
|
||||||
child: imageUrl == null
|
),
|
||||||
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
title: Text(
|
title: Text(
|
||||||
artist.name,
|
artist.name,
|
||||||
style: textTheme.titleMedium?.copyWith(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
|||||||
@@ -106,7 +106,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Recently played albums (with caching)
|
// Recently played albums (with caching)
|
||||||
|
// Stable keys prevent widget recreation on parent rebuilds
|
||||||
AlbumRow(
|
AlbumRow(
|
||||||
|
key: const ValueKey('recent-albums'),
|
||||||
title: 'Recently Played',
|
title: 'Recently Played',
|
||||||
loadAlbums: () => provider.getRecentAlbumsWithCache(),
|
loadAlbums: () => provider.getRecentAlbumsWithCache(),
|
||||||
),
|
),
|
||||||
@@ -114,6 +116,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
|
|
||||||
// Discover Artists (with caching)
|
// Discover Artists (with caching)
|
||||||
ArtistRow(
|
ArtistRow(
|
||||||
|
key: const ValueKey('discover-artists'),
|
||||||
title: 'Discover Artists',
|
title: 'Discover Artists',
|
||||||
loadArtists: () => provider.getDiscoverArtistsWithCache(),
|
loadArtists: () => provider.getDiscoverArtistsWithCache(),
|
||||||
),
|
),
|
||||||
@@ -121,6 +124,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
|
|
||||||
// Discover Albums (with caching)
|
// Discover Albums (with caching)
|
||||||
AlbumRow(
|
AlbumRow(
|
||||||
|
key: const ValueKey('discover-albums'),
|
||||||
title: 'Discover Albums',
|
title: 'Discover Albums',
|
||||||
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
|
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../models/media_item.dart';
|
|||||||
import '../widgets/global_player_overlay.dart';
|
import '../widgets/global_player_overlay.dart';
|
||||||
import '../widgets/player_selector.dart';
|
import '../widgets/player_selector.dart';
|
||||||
import '../widgets/album_card.dart';
|
import '../widgets/album_card.dart';
|
||||||
|
import '../widgets/artist_avatar.dart';
|
||||||
import '../utils/page_transitions.dart';
|
import '../utils/page_transitions.dart';
|
||||||
import '../constants/hero_tags.dart';
|
import '../constants/hero_tags.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
@@ -208,8 +209,6 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
Artist artist, {
|
Artist artist, {
|
||||||
Key? key,
|
Key? key,
|
||||||
}) {
|
}) {
|
||||||
final provider = context.read<MusicAssistantProvider>();
|
|
||||||
final imageUrl = provider.getImageUrl(artist, size: 128);
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
final suffix = '_library';
|
final suffix = '_library';
|
||||||
@@ -217,17 +216,15 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
return RepaintBoundary(
|
return RepaintBoundary(
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
key: key,
|
key: key,
|
||||||
leading: Hero(
|
leading: ArtistAvatar(
|
||||||
tag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + suffix,
|
artist: artist,
|
||||||
child: CircleAvatar(
|
|
||||||
radius: 24,
|
radius: 24,
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
imageSize: 128,
|
||||||
backgroundImage: imageUrl != null ? CachedNetworkImageProvider(imageUrl) : null,
|
heroTag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + suffix,
|
||||||
child: imageUrl == null
|
onImageLoaded: (imageUrl) {
|
||||||
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
|
// Store for adaptive colors on tap
|
||||||
: null,
|
},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
title: Hero(
|
title: Hero(
|
||||||
tag: HeroTags.artistName + (artist.uri ?? artist.itemId) + suffix,
|
tag: HeroTags.artistName + (artist.uri ?? artist.itemId) + suffix,
|
||||||
child: Material(
|
child: Material(
|
||||||
@@ -244,8 +241,6 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// Update adaptive colors immediately on tap
|
|
||||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
FadeSlidePageRoute(
|
FadeSlidePageRoute(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../services/debug_logger.dart';
|
|||||||
import '../widgets/global_player_overlay.dart';
|
import '../widgets/global_player_overlay.dart';
|
||||||
import '../widgets/common/empty_state.dart';
|
import '../widgets/common/empty_state.dart';
|
||||||
import '../widgets/common/disconnected_state.dart';
|
import '../widgets/common/disconnected_state.dart';
|
||||||
|
import '../widgets/artist_avatar.dart';
|
||||||
import 'album_details_screen.dart';
|
import 'album_details_screen.dart';
|
||||||
import 'artist_details_screen.dart';
|
import 'artist_details_screen.dart';
|
||||||
|
|
||||||
@@ -389,18 +390,13 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildArtistTile(Artist artist) {
|
Widget _buildArtistTile(Artist artist) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
|
||||||
final imageUrl = maProvider.getImageUrl(artist, size: 128);
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: CircleAvatar(
|
leading: ArtistAvatar(
|
||||||
|
artist: artist,
|
||||||
radius: 24,
|
radius: 24,
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
imageSize: 128,
|
||||||
backgroundImage: imageUrl != null ? CachedNetworkImageProvider(imageUrl) : null,
|
|
||||||
child: imageUrl == null
|
|
||||||
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
artist.name,
|
artist.name,
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Add API keys to fetch artist biographies and album descriptions when Music Assistant doesn\'t have them',
|
'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.',
|
||||||
style: textTheme.bodySmall?.copyWith(
|
style: textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onBackground.withOpacity(0.6),
|
color: colorScheme.onBackground.withOpacity(0.6),
|
||||||
),
|
),
|
||||||
@@ -365,7 +365,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
controller: _audioDbApiKeyController,
|
controller: _audioDbApiKeyController,
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'TheAudioDB API Key (Premium)',
|
labelText: 'TheAudioDB API Key',
|
||||||
hintText: 'Use "2" for free tier or premium key',
|
hintText: 'Use "2" for free tier or premium key',
|
||||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||||
filled: true,
|
filled: true,
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ class MetadataService {
|
|||||||
// Cache to avoid repeated API calls for the same artist/album
|
// Cache to avoid repeated API calls for the same artist/album
|
||||||
static final Map<String, String> _cache = {};
|
static final Map<String, String> _cache = {};
|
||||||
|
|
||||||
|
// Cache for artist images
|
||||||
|
static final Map<String, String?> _artistImageCache = {};
|
||||||
|
|
||||||
/// Fetches artist biography/description with fallback chain:
|
/// Fetches artist biography/description with fallback chain:
|
||||||
/// 1. Music Assistant metadata (passed in)
|
/// 1. Music Assistant metadata (passed in)
|
||||||
/// 2. Last.fm API (if key configured)
|
/// 2. Last.fm API (if key configured)
|
||||||
@@ -204,8 +207,51 @@ class MetadataService {
|
|||||||
return text.trim();
|
return text.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetches artist image URL from Deezer (free, no API key required)
|
||||||
|
/// Returns the image URL if found, null otherwise
|
||||||
|
static Future<String?> getArtistImageUrl(String artistName) async {
|
||||||
|
// Check cache first
|
||||||
|
final cacheKey = 'artistImage:$artistName';
|
||||||
|
if (_artistImageCache.containsKey(cacheKey)) {
|
||||||
|
return _artistImageCache[cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Deezer API (free, no key, excellent coverage)
|
||||||
|
try {
|
||||||
|
final uri = Uri.https(
|
||||||
|
'api.deezer.com',
|
||||||
|
'/search/artist',
|
||||||
|
{
|
||||||
|
'q': artistName,
|
||||||
|
'limit': '1',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await http.get(uri).timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = json.decode(response.body);
|
||||||
|
final artists = data['data'] as List?;
|
||||||
|
if (artists != null && artists.isNotEmpty) {
|
||||||
|
final artist = artists[0];
|
||||||
|
// Use picture_xl for high quality, fall back to picture_medium
|
||||||
|
final imageUrl = artist['picture_xl'] ?? artist['picture_medium'] ?? artist['picture'];
|
||||||
|
_artistImageCache[cacheKey] = imageUrl;
|
||||||
|
return imageUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('⚠️ Deezer artist image error: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the null result to avoid repeated failed lookups
|
||||||
|
_artistImageCache[cacheKey] = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Clears the metadata cache
|
/// Clears the metadata cache
|
||||||
static void clearCache() {
|
static void clearCache() {
|
||||||
_cache.clear();
|
_cache.clear();
|
||||||
|
_artistImageCache.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -749,35 +749,18 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Favorites
|
// Favorites
|
||||||
/// Toggle favorite status for any media item
|
/// Mark item as favorite using URI format
|
||||||
Future<bool> toggleFavorite(String mediaType, String itemId, String provider) async {
|
/// The item parameter should be a URI like "spotify://album/6XyFhbFtcRRR9peJ16em4h"
|
||||||
try {
|
|
||||||
final response = await _sendCommand(
|
|
||||||
'music/favorites/toggle',
|
|
||||||
args: {
|
|
||||||
'media_type': mediaType,
|
|
||||||
'item_id': itemId,
|
|
||||||
'provider': provider,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
final isFavorite = response['result'] as bool? ?? false;
|
|
||||||
return isFavorite;
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('Error toggling favorite: $e');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mark item as favorite
|
|
||||||
Future<void> addToFavorites(String mediaType, String itemId, String provider) async {
|
Future<void> addToFavorites(String mediaType, String itemId, String provider) async {
|
||||||
try {
|
try {
|
||||||
|
// Build the URI for the item
|
||||||
|
final uri = '$provider://$mediaType/$itemId';
|
||||||
|
_logger.log('Adding to favorites: $uri');
|
||||||
|
|
||||||
await _sendCommand(
|
await _sendCommand(
|
||||||
'music/favorites/add',
|
'music/favorites/add_item',
|
||||||
args: {
|
args: {
|
||||||
'media_type': mediaType,
|
'item': uri,
|
||||||
'item_id': itemId,
|
|
||||||
'provider': provider,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -787,14 +770,16 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Remove item from favorites
|
/// Remove item from favorites
|
||||||
Future<void> removeFromFavorites(String mediaType, String itemId, String provider) async {
|
/// Requires the library_item_id (the numeric ID in the MA library)
|
||||||
|
Future<void> removeFromFavorites(String mediaType, int libraryItemId) async {
|
||||||
try {
|
try {
|
||||||
|
_logger.log('Removing from favorites: mediaType=$mediaType, libraryItemId=$libraryItemId');
|
||||||
|
|
||||||
await _sendCommand(
|
await _sendCommand(
|
||||||
'music/favorites/remove',
|
'music/favorites/remove_item',
|
||||||
args: {
|
args: {
|
||||||
'media_type': mediaType,
|
'media_type': mediaType,
|
||||||
'item_id': itemId,
|
'library_item_id': libraryItemId,
|
||||||
'provider': provider,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../models/media_item.dart';
|
||||||
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../services/metadata_service.dart';
|
||||||
|
|
||||||
|
/// A CircleAvatar that shows artist image with automatic fallback to Deezer/Fanart.tv
|
||||||
|
class ArtistAvatar extends StatefulWidget {
|
||||||
|
final Artist artist;
|
||||||
|
final double radius;
|
||||||
|
final int imageSize;
|
||||||
|
final String? heroTag;
|
||||||
|
final ValueChanged<String?>? onImageLoaded;
|
||||||
|
|
||||||
|
const ArtistAvatar({
|
||||||
|
super.key,
|
||||||
|
required this.artist,
|
||||||
|
this.radius = 24,
|
||||||
|
this.imageSize = 128,
|
||||||
|
this.heroTag,
|
||||||
|
this.onImageLoaded,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ArtistAvatar> createState() => _ArtistAvatarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ArtistAvatarState extends State<ArtistAvatar> {
|
||||||
|
String? _imageUrl;
|
||||||
|
bool _triedFallback = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadImage() async {
|
||||||
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
|
// Try MA first
|
||||||
|
final maUrl = provider.getImageUrl(widget.artist, size: widget.imageSize);
|
||||||
|
if (maUrl != null) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_imageUrl = maUrl;
|
||||||
|
});
|
||||||
|
widget.onImageLoaded?.call(maUrl);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to external sources
|
||||||
|
if (!_triedFallback) {
|
||||||
|
_triedFallback = true;
|
||||||
|
final fallbackUrl = await MetadataService.getArtistImageUrl(widget.artist.name);
|
||||||
|
if (fallbackUrl != null && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_imageUrl = fallbackUrl;
|
||||||
|
});
|
||||||
|
widget.onImageLoaded?.call(fallbackUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
final avatar = CircleAvatar(
|
||||||
|
radius: widget.radius,
|
||||||
|
backgroundColor: colorScheme.surfaceVariant,
|
||||||
|
backgroundImage: _imageUrl != null ? CachedNetworkImageProvider(_imageUrl!) : null,
|
||||||
|
child: _imageUrl == null
|
||||||
|
? Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (widget.heroTag != null) {
|
||||||
|
return Hero(
|
||||||
|
tag: widget.heroTag!,
|
||||||
|
child: avatar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return avatar;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,9 @@ import '../screens/artist_details_screen.dart';
|
|||||||
import '../constants/hero_tags.dart';
|
import '../constants/hero_tags.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
import '../utils/page_transitions.dart';
|
import '../utils/page_transitions.dart';
|
||||||
|
import '../services/metadata_service.dart';
|
||||||
|
|
||||||
class ArtistCard extends StatelessWidget {
|
class ArtistCard extends StatefulWidget {
|
||||||
final Artist artist;
|
final Artist artist;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final String? heroTagSuffix;
|
final String? heroTagSuffix;
|
||||||
@@ -19,26 +20,46 @@ class ArtistCard extends StatelessWidget {
|
|||||||
this.heroTagSuffix,
|
this.heroTagSuffix,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ArtistCard> createState() => _ArtistCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ArtistCardState extends State<ArtistCard> {
|
||||||
|
String? _fallbackImageUrl;
|
||||||
|
bool _triedFallback = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final imageUrl = maProvider.api?.getImageUrl(artist, size: 256);
|
final maImageUrl = maProvider.api?.getImageUrl(widget.artist, size: 256);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
|
final suffix = widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||||
|
|
||||||
|
// Use MA image if available, otherwise try fallback
|
||||||
|
final imageUrl = maImageUrl ?? _fallbackImageUrl;
|
||||||
|
|
||||||
|
// Try to fetch fallback image if MA doesn't have one and we haven't tried yet
|
||||||
|
if (maImageUrl == null && !_triedFallback) {
|
||||||
|
_triedFallback = true;
|
||||||
|
print('🎨 No MA image for "${widget.artist.name}", trying fallback...');
|
||||||
|
_fetchFallbackImage();
|
||||||
|
} else if (maImageUrl != null && !_triedFallback) {
|
||||||
|
print('🎨 MA has image for "${widget.artist.name}": $maImageUrl');
|
||||||
|
}
|
||||||
|
|
||||||
return RepaintBoundary(
|
return RepaintBoundary(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: onTap ?? () {
|
onTap: widget.onTap ?? () {
|
||||||
// Update adaptive colors immediately on tap
|
// Update adaptive colors immediately on tap
|
||||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
FadeSlidePageRoute(
|
FadeSlidePageRoute(
|
||||||
child: ArtistDetailsScreen(
|
child: ArtistDetailsScreen(
|
||||||
artist: artist,
|
artist: widget.artist,
|
||||||
heroTagSuffix: heroTagSuffix,
|
heroTagSuffix: widget.heroTagSuffix,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -48,7 +69,7 @@ class ArtistCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
// Artist image - circular
|
// Artist image - circular
|
||||||
Hero(
|
Hero(
|
||||||
tag: HeroTags.artistImage + (artist.uri ?? artist.itemId) + suffix,
|
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + suffix,
|
||||||
child: ClipOval(
|
child: ClipOval(
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 110,
|
width: 110,
|
||||||
@@ -73,11 +94,11 @@ class ArtistCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
// Artist name
|
// Artist name
|
||||||
Hero(
|
Hero(
|
||||||
tag: HeroTags.artistName + (artist.uri ?? artist.itemId) + suffix,
|
tag: HeroTags.artistName + (widget.artist.uri ?? widget.artist.itemId) + suffix,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: Text(
|
child: Text(
|
||||||
artist.name,
|
widget.artist.name,
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
@@ -93,4 +114,14 @@ class ArtistCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchFallbackImage() async {
|
||||||
|
final fallbackUrl = await MetadataService.getArtistImageUrl(widget.artist.name);
|
||||||
|
print('🎨 Fallback result for "${widget.artist.name}": $fallbackUrl');
|
||||||
|
if (fallbackUrl != null && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_fallbackImageUrl = fallbackUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+612
-154
@@ -7,7 +7,6 @@ import 'package:cached_network_image/cached_network_image.dart';
|
|||||||
import '../constants/timings.dart';
|
import '../constants/timings.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../models/player.dart';
|
import '../models/player.dart';
|
||||||
import '../services/debug_logger.dart';
|
|
||||||
import '../theme/palette_helper.dart';
|
import '../theme/palette_helper.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
import 'animated_icon_button.dart';
|
import 'animated_icon_button.dart';
|
||||||
@@ -68,10 +67,19 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
// Track horizontal drag start position
|
// Track horizontal drag start position
|
||||||
double? _horizontalDragStartX;
|
double? _horizontalDragStartX;
|
||||||
|
|
||||||
// Slide animation for device switching
|
// Slide animation for device switching - now supports finger-following
|
||||||
late AnimationController _slideController;
|
late AnimationController _slideController;
|
||||||
double _slideOffset = 0.0; // -1 to 1, negative = sliding left, positive = sliding right
|
double _slideOffset = 0.0; // -1 to 1, negative = sliding left, positive = sliding right
|
||||||
bool _isSliding = false;
|
bool _isSliding = false;
|
||||||
|
bool _isDragging = false; // True while finger is actively dragging
|
||||||
|
|
||||||
|
// For peek preview - track which player we'd switch to
|
||||||
|
dynamic _peekPlayer; // The player that would be selected if swipe commits
|
||||||
|
String? _peekImageUrl; // Image URL for peek player's current track
|
||||||
|
|
||||||
|
// Flag to indicate we're in the middle of a player switch transition
|
||||||
|
// When true, we hide main content and show peek content at center
|
||||||
|
bool _inTransition = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -100,9 +108,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
reverseCurve: Curves.easeInCubic,
|
reverseCurve: Curves.easeInCubic,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Slide animation for device switching
|
// Slide animation for device switching - used for snap/spring animations
|
||||||
_slideController = AnimationController(
|
_slideController = AnimationController(
|
||||||
duration: const Duration(milliseconds: 75),
|
duration: const Duration(milliseconds: 250),
|
||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -239,7 +247,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2);
|
themeProvider.updateAdaptiveColors(colorSchemes.$1, colorSchemes.$2);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
DebugLogger().log('Player: Failed to extract album colors');
|
// Silently ignore color extraction errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +289,29 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
return maProvider.availablePlayers.where((p) => p.available).toList();
|
return maProvider.availablePlayers.where((p) => p.available).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the next or previous player relative to current selection
|
||||||
|
dynamic _getAdjacentPlayer(MusicAssistantProvider maProvider, {required bool next}) {
|
||||||
|
final players = _getAvailablePlayersSorted(maProvider);
|
||||||
|
if (players.length <= 1) return null;
|
||||||
|
|
||||||
|
final selectedPlayerId = maProvider.selectedPlayer?.playerId;
|
||||||
|
final currentIndex = players.indexWhere((p) => p.playerId == selectedPlayerId);
|
||||||
|
|
||||||
|
if (currentIndex == -1) return players[0];
|
||||||
|
|
||||||
|
int adjacentIndex;
|
||||||
|
if (next) {
|
||||||
|
adjacentIndex = currentIndex >= players.length - 1 ? 0 : currentIndex + 1;
|
||||||
|
} else {
|
||||||
|
adjacentIndex = currentIndex <= 0 ? players.length - 1 : currentIndex - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return players[adjacentIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache for peek track data
|
||||||
|
dynamic _peekTrack;
|
||||||
|
|
||||||
/// Cycle to the next available player (for swipe gesture)
|
/// Cycle to the next available player (for swipe gesture)
|
||||||
void _cycleToNextPlayer(MusicAssistantProvider maProvider, {bool reverse = false}) {
|
void _cycleToNextPlayer(MusicAssistantProvider maProvider, {bool reverse = false}) {
|
||||||
final players = _getAvailablePlayersSorted(maProvider);
|
final players = _getAvailablePlayersSorted(maProvider);
|
||||||
@@ -312,66 +343,251 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Animate the slide transition when switching devices
|
/// Handle real-time drag updates for finger-following swipe
|
||||||
/// direction: -1 = swipe left (content exits left, new enters from right)
|
void _handleHorizontalDragUpdate(DragUpdateDetails details, MusicAssistantProvider maProvider, double containerWidth) {
|
||||||
/// 1 = swipe right (content exits right, new enters from left)
|
if (_isSliding) return;
|
||||||
void _animateSlide(int direction, VoidCallback onComplete) {
|
|
||||||
|
// Start tracking if not already
|
||||||
|
if (!_isDragging) {
|
||||||
|
_isDragging = true;
|
||||||
|
_peekPlayer = null;
|
||||||
|
_peekImageUrl = null;
|
||||||
|
_peekTrack = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate normalized offset (-1 to 1 range)
|
||||||
|
// Negative = dragging left (showing next player from right)
|
||||||
|
// Positive = dragging right (showing previous player from left)
|
||||||
|
final delta = details.primaryDelta ?? 0;
|
||||||
|
final normalizedDelta = delta / containerWidth;
|
||||||
|
final newSlideOffset = (_slideOffset + normalizedDelta).clamp(-1.0, 1.0);
|
||||||
|
|
||||||
|
// Update peek player based on drag direction (must be inside setState to trigger rebuild)
|
||||||
|
setState(() {
|
||||||
|
_slideOffset = newSlideOffset;
|
||||||
|
if (_slideOffset != 0) {
|
||||||
|
_updatePeekPlayerState(maProvider, _slideOffset);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update peek player state variables (called inside setState)
|
||||||
|
void _updatePeekPlayerState(MusicAssistantProvider maProvider, double dragDirection) {
|
||||||
|
// dragDirection < 0 means swiping left (next player)
|
||||||
|
// dragDirection > 0 means swiping right (previous player)
|
||||||
|
final isNext = dragDirection < 0;
|
||||||
|
final newPeekPlayer = _getAdjacentPlayer(maProvider, next: isNext);
|
||||||
|
|
||||||
|
if (newPeekPlayer?.playerId != _peekPlayer?.playerId) {
|
||||||
|
_peekPlayer = newPeekPlayer;
|
||||||
|
// Get the peek player's current track image if available
|
||||||
|
if (_peekPlayer != null) {
|
||||||
|
_peekTrack = maProvider.getCachedTrackForPlayer(_peekPlayer.playerId);
|
||||||
|
_peekImageUrl = _peekTrack != null ? maProvider.getImageUrl(_peekTrack, size: 512) : null;
|
||||||
|
} else {
|
||||||
|
_peekTrack = null;
|
||||||
|
_peekImageUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle drag end - either commit to next player or snap back
|
||||||
|
void _handleHorizontalDragEnd(DragEndDetails details, MusicAssistantProvider maProvider) {
|
||||||
|
if (!_isDragging || _isSliding) {
|
||||||
|
_isDragging = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isDragging = false;
|
||||||
|
final velocity = details.primaryVelocity ?? 0;
|
||||||
|
|
||||||
|
// Thresholds for committing the swipe
|
||||||
|
const commitThreshold = 0.3; // 30% of width
|
||||||
|
const velocityThreshold = 500.0; // px/s
|
||||||
|
|
||||||
|
final shouldCommit = _slideOffset.abs() > commitThreshold || velocity.abs() > velocityThreshold;
|
||||||
|
final direction = _slideOffset != 0 ? _slideOffset.sign : (velocity != 0 ? -velocity.sign : 0);
|
||||||
|
|
||||||
|
if (shouldCommit && _peekPlayer != null && direction != 0) {
|
||||||
|
// Commit: animate to full slide, switch player, then reset
|
||||||
|
// Note: Don't clear _peekPlayer here - let _animateCommit handle cleanup
|
||||||
|
// so the peek content can remain visible during the transition
|
||||||
|
_animateCommit(direction.toInt(), () {
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
maProvider.selectPlayer(_peekPlayer);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Cancel: spring back to center
|
||||||
|
_animateSnapBack();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Animate committing to the next/previous player
|
||||||
|
void _animateCommit(int direction, VoidCallback onSwitch) {
|
||||||
if (_isSliding) return;
|
if (_isSliding) return;
|
||||||
_isSliding = true;
|
_isSliding = true;
|
||||||
|
|
||||||
final slideOutTarget = direction.toDouble(); // Where current content goes
|
// Mark transition BEFORE animation - this keeps peek content visible
|
||||||
final slideInStart = -direction.toDouble(); // Where new content comes from
|
// and hides main content to prevent any flash
|
||||||
|
_inTransition = true;
|
||||||
|
|
||||||
// Use easing curves for smoother feel
|
final startOffset = _slideOffset;
|
||||||
const outCurve = Curves.easeInCubic; // Accelerate out
|
final targetOffset = direction < 0 ? -1.0 : 1.0;
|
||||||
const inCurve = Curves.easeOutCubic; // Decelerate in
|
|
||||||
|
|
||||||
// Phase 1: Slide out current content
|
|
||||||
_slideOffset = 0.0;
|
|
||||||
_slideController.reset();
|
_slideController.reset();
|
||||||
|
|
||||||
void animateOut() {
|
void animateToTarget() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final curvedValue = outCurve.transform(_slideController.value);
|
final curvedValue = Curves.easeOutCubic.transform(_slideController.value);
|
||||||
setState(() {
|
setState(() {
|
||||||
_slideOffset = slideOutTarget * curvedValue;
|
_slideOffset = startOffset + (targetOffset - startOffset) * curvedValue;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void animateIn() {
|
_slideController.addListener(animateToTarget);
|
||||||
if (!mounted) return;
|
_slideController.duration = const Duration(milliseconds: 150);
|
||||||
final curvedValue = inCurve.transform(_slideController.value);
|
|
||||||
setState(() {
|
|
||||||
_slideOffset = slideInStart * (1.0 - curvedValue);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_slideController.addListener(animateOut);
|
|
||||||
|
|
||||||
_slideController.forward().then((_) {
|
_slideController.forward().then((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
_slideController.removeListener(animateOut);
|
_slideController.removeListener(animateToTarget);
|
||||||
|
|
||||||
// Switch the data
|
// Cache the peek data BEFORE switching - we'll use this to crossfade
|
||||||
onComplete();
|
final cachedPeekPlayer = _peekPlayer;
|
||||||
|
final cachedPeekTrack = _peekTrack;
|
||||||
|
final cachedPeekImageUrl = _peekImageUrl;
|
||||||
|
|
||||||
// Phase 2: Slide in new content
|
// Switch the player - this sets currentTrack from cache in the provider
|
||||||
_slideOffset = slideInStart;
|
onSwitch();
|
||||||
_slideController.reset();
|
|
||||||
_slideController.addListener(animateIn);
|
|
||||||
|
|
||||||
_slideController.forward().then((_) {
|
// Get the new track state
|
||||||
if (!mounted) return;
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
_slideController.removeListener(animateIn);
|
final newTrack = maProvider.currentTrack;
|
||||||
setState(() {
|
|
||||||
_slideOffset = 0.0;
|
// Move peek content to center position (slideOffset = 0) while keeping it visible.
|
||||||
_isSliding = false;
|
// The main content is hidden via _inTransition flag.
|
||||||
});
|
// This creates a seamless visual - peek content is already at center.
|
||||||
|
setState(() {
|
||||||
|
_slideOffset = 0.0;
|
||||||
|
_isSliding = false;
|
||||||
|
// Keep _inTransition = true and peek data intact
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (newTrack != null) {
|
||||||
|
// Switching to a playing player - need to wait for image to be ready
|
||||||
|
// before showing main content and hiding peek
|
||||||
|
final newImageUrl = maProvider.getImageUrl(newTrack, size: 512);
|
||||||
|
|
||||||
|
if (newImageUrl != null && newImageUrl == cachedPeekImageUrl) {
|
||||||
|
// Same image - can transition immediately
|
||||||
|
_completeTransition();
|
||||||
|
} else if (newImageUrl != null) {
|
||||||
|
// Different image - precache it first, then transition
|
||||||
|
_precacheAndTransition(newImageUrl);
|
||||||
|
} else {
|
||||||
|
// No image - transition after frame
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) _completeTransition();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Switching to non-playing player - DeviceSelectorBar will show
|
||||||
|
// Wait for it to render, then complete transition
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) _completeTransition();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_slideController.duration = const Duration(milliseconds: 250); // Reset default
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Precache an image and then complete the transition
|
||||||
|
void _precacheAndTransition(String imageUrl) {
|
||||||
|
// Use CachedNetworkImage's cache to ensure image is ready
|
||||||
|
final imageProvider = CachedNetworkImageProvider(imageUrl);
|
||||||
|
final imageStream = imageProvider.resolve(const ImageConfiguration());
|
||||||
|
late ImageStreamListener listener;
|
||||||
|
|
||||||
|
listener = ImageStreamListener(
|
||||||
|
(ImageInfo info, bool synchronousCall) {
|
||||||
|
imageStream.removeListener(listener);
|
||||||
|
if (mounted) {
|
||||||
|
// Image is now in memory cache - safe to transition
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) _completeTransition();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (exception, stackTrace) {
|
||||||
|
imageStream.removeListener(listener);
|
||||||
|
// Even on error, complete the transition
|
||||||
|
if (mounted) _completeTransition();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
imageStream.addListener(listener);
|
||||||
|
|
||||||
|
// Safety timeout - don't wait forever
|
||||||
|
Future.delayed(const Duration(milliseconds: 500), () {
|
||||||
|
if (mounted && _inTransition) {
|
||||||
|
imageStream.removeListener(listener);
|
||||||
|
_completeTransition();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete the transition by hiding peek content and showing main content
|
||||||
|
void _completeTransition() {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_inTransition = false;
|
||||||
|
_peekPlayer = null;
|
||||||
|
_peekTrack = null;
|
||||||
|
_peekImageUrl = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Animate snapping back to center (cancelled swipe)
|
||||||
|
void _animateSnapBack() {
|
||||||
|
if (_isSliding) return;
|
||||||
|
_isSliding = true;
|
||||||
|
|
||||||
|
final startOffset = _slideOffset;
|
||||||
|
_slideController.reset();
|
||||||
|
|
||||||
|
void animateBack() {
|
||||||
|
if (!mounted) return;
|
||||||
|
final curvedValue = Curves.easeOutBack.transform(_slideController.value);
|
||||||
|
setState(() {
|
||||||
|
_slideOffset = startOffset * (1.0 - curvedValue);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_slideController.addListener(animateBack);
|
||||||
|
_slideController.duration = const Duration(milliseconds: 300);
|
||||||
|
|
||||||
|
_slideController.forward().then((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_slideController.removeListener(animateBack);
|
||||||
|
setState(() {
|
||||||
|
_slideOffset = 0.0;
|
||||||
|
_isSliding = false;
|
||||||
|
_peekPlayer = null;
|
||||||
|
_peekImageUrl = null;
|
||||||
|
});
|
||||||
|
_slideController.duration = const Duration(milliseconds: 250); // Reset default
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy method - now calls new commit animation
|
||||||
|
void _animateSlide(int direction, VoidCallback onComplete) {
|
||||||
|
_animateCommit(direction, onComplete);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final themeProvider = context.watch<ThemeProvider>();
|
final themeProvider = context.watch<ThemeProvider>();
|
||||||
@@ -386,6 +602,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get image URL for current track
|
||||||
final imageUrl = currentTrack != null
|
final imageUrl = currentTrack != null
|
||||||
? maProvider.getImageUrl(currentTrack, size: 512)
|
? maProvider.getImageUrl(currentTrack, size: 512)
|
||||||
: null;
|
: null;
|
||||||
@@ -398,7 +615,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: Listenable.merge([_expandAnimation, _queuePanelAnimation]),
|
animation: Listenable.merge([_expandAnimation, _queuePanelAnimation]),
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
// If no track is playing, show a compact device selector bar
|
// If no track is playing, show device selector bar
|
||||||
if (currentTrack == null) {
|
if (currentTrack == null) {
|
||||||
return _buildDeviceSelectorBar(context, maProvider, selectedPlayer, themeProvider);
|
return _buildDeviceSelectorBar(context, maProvider, selectedPlayer, themeProvider);
|
||||||
}
|
}
|
||||||
@@ -457,6 +674,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
bottom: adjustedBottomOffset,
|
bottom: adjustedBottomOffset,
|
||||||
child: DeviceSelectorBar(
|
child: DeviceSelectorBar(
|
||||||
selectedPlayer: selectedPlayer,
|
selectedPlayer: selectedPlayer,
|
||||||
|
peekPlayer: _peekPlayer,
|
||||||
hasMultiplePlayers: hasMultiplePlayers,
|
hasMultiplePlayers: hasMultiplePlayers,
|
||||||
backgroundColor: backgroundColor,
|
backgroundColor: backgroundColor,
|
||||||
textColor: textColor,
|
textColor: textColor,
|
||||||
@@ -464,14 +682,30 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
height: _collapsedHeight,
|
height: _collapsedHeight,
|
||||||
borderRadius: _collapsedBorderRadius,
|
borderRadius: _collapsedBorderRadius,
|
||||||
slideOffset: _slideOffset,
|
slideOffset: _slideOffset,
|
||||||
|
onHorizontalDragStart: (details) {
|
||||||
|
_horizontalDragStartX = details.globalPosition.dx;
|
||||||
|
},
|
||||||
|
onHorizontalDragUpdate: (details) {
|
||||||
|
// Check for edge dead zone
|
||||||
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
|
final startedInDeadZone = _horizontalDragStartX != null &&
|
||||||
|
(_horizontalDragStartX! > screenWidth - _edgeDeadZone ||
|
||||||
|
_horizontalDragStartX! < _edgeDeadZone);
|
||||||
|
if (startedInDeadZone) return;
|
||||||
|
|
||||||
|
_handleHorizontalDragUpdate(details, maProvider, width);
|
||||||
|
},
|
||||||
onHorizontalDragEnd: (details) {
|
onHorizontalDragEnd: (details) {
|
||||||
if (details.primaryVelocity != null) {
|
// Check for edge dead zone
|
||||||
if (details.primaryVelocity! < -300) {
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
_cycleToNextPlayer(maProvider);
|
final startedInDeadZone = _horizontalDragStartX != null &&
|
||||||
} else if (details.primaryVelocity! > 300) {
|
(_horizontalDragStartX! > screenWidth - _edgeDeadZone ||
|
||||||
_cycleToNextPlayer(maProvider, reverse: true);
|
_horizontalDragStartX! < _edgeDeadZone);
|
||||||
}
|
_horizontalDragStartX = null;
|
||||||
}
|
|
||||||
|
if (startedInDeadZone) return;
|
||||||
|
|
||||||
|
_handleHorizontalDragEnd(details, maProvider);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -582,8 +816,6 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
final titleLeft = _lerpDouble(collapsedTitleLeft, expandedTitleLeft, t);
|
final titleLeft = _lerpDouble(collapsedTitleLeft, expandedTitleLeft, t);
|
||||||
|
|
||||||
final collapsedTitleTop = (_collapsedHeight - 36) / 2; // Centered vertically (adjusted for increased track/artist gap)
|
final collapsedTitleTop = (_collapsedHeight - 36) / 2; // Centered vertically (adjusted for increased track/artist gap)
|
||||||
final expandedTitleTop = expandedArtTop + expandedArtSize + 28;
|
|
||||||
final titleTop = _lerpDouble(collapsedTitleTop, expandedTitleTop, t);
|
|
||||||
|
|
||||||
final collapsedTitleWidth = screenSize.width - _collapsedArtSize - 150;
|
final collapsedTitleWidth = screenSize.width - _collapsedArtSize - 150;
|
||||||
final expandedTitleWidth = screenSize.width - (contentPadding * 2);
|
final expandedTitleWidth = screenSize.width - (contentPadding * 2);
|
||||||
@@ -603,16 +835,43 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
)..layout(maxWidth: expandedTitleWidth);
|
)..layout(maxWidth: expandedTitleWidth);
|
||||||
final expandedTitleHeight = titlePainter.height;
|
final expandedTitleHeight = titlePainter.height;
|
||||||
|
|
||||||
|
// Calculate track info block height (title + gap + artist + gap + album)
|
||||||
|
final titleToArtistGap = 12.0;
|
||||||
|
final artistToAlbumGap = 8.0;
|
||||||
|
final artistHeight = 22.0; // Approximate height for 18px font
|
||||||
|
final albumHeight = currentTrack.album != null ? 20.0 : 0.0; // Album line or nothing
|
||||||
|
final trackInfoBlockHeight = expandedTitleHeight + titleToArtistGap + artistHeight +
|
||||||
|
(currentTrack.album != null ? artistToAlbumGap + albumHeight : 0.0);
|
||||||
|
|
||||||
|
// Controls section heights (from bottom up):
|
||||||
|
// - Volume slider: 48px
|
||||||
|
// - Gap: 40px (was 88-48=40 from volumeTop calculation)
|
||||||
|
// - Controls row: ~70px (centered at expandedControlsTop)
|
||||||
|
// - Gap: 64px (from expandedControlsTop = expandedProgressTop + 64)
|
||||||
|
// - Progress bar + times: ~70px
|
||||||
|
// Total from bottom edge: ~48 + 40 + 70 + 64 = 222, plus safe area padding
|
||||||
|
// Position progress bar so controls section is anchored at bottom
|
||||||
|
final bottomSafeArea = MediaQuery.of(context).padding.bottom;
|
||||||
|
final controlsSectionHeight = 222.0; // Total height of progress + controls + volume
|
||||||
|
final playButtonHalfHeight = 36.0; // Half of the 72px play button container
|
||||||
|
final expandedProgressTop = screenSize.height - bottomSafeArea - controlsSectionHeight - 24 - playButtonHalfHeight;
|
||||||
|
|
||||||
|
// Calculate available space between art bottom and progress bar
|
||||||
|
final artBottom = expandedArtTop + expandedArtSize;
|
||||||
|
final availableSpace = expandedProgressTop - artBottom;
|
||||||
|
|
||||||
|
// Center the track info block in available space
|
||||||
|
final trackInfoTopMargin = (availableSpace - trackInfoBlockHeight) / 2;
|
||||||
|
final expandedTitleTop = artBottom + trackInfoTopMargin;
|
||||||
|
final titleTop = _lerpDouble(collapsedTitleTop, expandedTitleTop, t);
|
||||||
|
|
||||||
// Artist positioned dynamically based on actual title height
|
// Artist positioned dynamically based on actual title height
|
||||||
final collapsedArtistTop = collapsedTitleTop + 22; // Increased gap from 18 to 22
|
final collapsedArtistTop = collapsedTitleTop + 22; // Increased gap from 18 to 22
|
||||||
final expandedArtistTop = expandedTitleTop + expandedTitleHeight + 12; // Increased from 8 to 12
|
final expandedArtistTop = expandedTitleTop + expandedTitleHeight + titleToArtistGap;
|
||||||
final artistTop = _lerpDouble(collapsedArtistTop, expandedArtistTop, t);
|
final artistTop = _lerpDouble(collapsedArtistTop, expandedArtistTop, t);
|
||||||
|
|
||||||
// Album - subtle, below artist
|
// Album - subtle, below artist
|
||||||
final expandedAlbumTop = expandedArtistTop + 28; // Increased from 24 to 28
|
final expandedAlbumTop = expandedArtistTop + artistHeight + artistToAlbumGap;
|
||||||
|
|
||||||
// Progress bar - with generous spacing
|
|
||||||
final expandedProgressTop = expandedAlbumTop + 106; // Increased from 36 to 106 (+70px)
|
|
||||||
|
|
||||||
// Controls - main row with comfortable touch targets
|
// Controls - main row with comfortable touch targets
|
||||||
final collapsedControlsRight = 8.0;
|
final collapsedControlsRight = 8.0;
|
||||||
@@ -661,31 +920,41 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
onHorizontalDragStart: (details) {
|
onHorizontalDragStart: (details) {
|
||||||
_horizontalDragStartX = details.globalPosition.dx;
|
_horizontalDragStartX = details.globalPosition.dx;
|
||||||
},
|
},
|
||||||
onHorizontalDragEnd: (details) {
|
onHorizontalDragUpdate: (details) {
|
||||||
// Ignore swipes that started near the right edge (Android back gesture zone)
|
// Only handle in collapsed mode with multiple players
|
||||||
|
if (isExpanded || !hasMultiplePlayers) return;
|
||||||
|
|
||||||
|
// Ignore drags that started in the edge dead zone
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
final startedInDeadZone = _horizontalDragStartX != null &&
|
final startedInDeadZone = _horizontalDragStartX != null &&
|
||||||
_horizontalDragStartX! > screenWidth - _edgeDeadZone;
|
(_horizontalDragStartX! > screenWidth - _edgeDeadZone ||
|
||||||
|
_horizontalDragStartX! < _edgeDeadZone);
|
||||||
|
if (startedInDeadZone) return;
|
||||||
|
|
||||||
|
_handleHorizontalDragUpdate(details, maProvider, collapsedWidth);
|
||||||
|
},
|
||||||
|
onHorizontalDragEnd: (details) {
|
||||||
|
// Ignore swipes that started near the edges (Android back gesture zone)
|
||||||
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
|
final startedInDeadZone = _horizontalDragStartX != null &&
|
||||||
|
(_horizontalDragStartX! > screenWidth - _edgeDeadZone ||
|
||||||
|
_horizontalDragStartX! < _edgeDeadZone);
|
||||||
_horizontalDragStartX = null;
|
_horizontalDragStartX = null;
|
||||||
|
|
||||||
if (startedInDeadZone) return;
|
if (startedInDeadZone) return;
|
||||||
|
|
||||||
if (details.primaryVelocity != null) {
|
if (isExpanded) {
|
||||||
if (isExpanded) {
|
// Expanded mode: swipe to open/close queue
|
||||||
// Expanded mode: swipe to open/close queue
|
if (details.primaryVelocity != null) {
|
||||||
if (details.primaryVelocity! < -300 && !isQueuePanelOpen) {
|
if (details.primaryVelocity! < -300 && !isQueuePanelOpen) {
|
||||||
_toggleQueuePanel();
|
_toggleQueuePanel();
|
||||||
} else if (details.primaryVelocity! > 300 && isQueuePanelOpen) {
|
} else if (details.primaryVelocity! > 300 && isQueuePanelOpen) {
|
||||||
_toggleQueuePanel();
|
_toggleQueuePanel();
|
||||||
}
|
}
|
||||||
} else if (hasMultiplePlayers) {
|
|
||||||
// Collapsed mode: swipe to switch devices
|
|
||||||
if (details.primaryVelocity! < -300) {
|
|
||||||
_cycleToNextPlayer(maProvider);
|
|
||||||
} else if (details.primaryVelocity! > 300) {
|
|
||||||
_cycleToNextPlayer(maProvider, reverse: true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else if (hasMultiplePlayers) {
|
||||||
|
// Collapsed mode: use finger-following handler
|
||||||
|
_handleHorizontalDragEnd(details, maProvider);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Material(
|
child: Material(
|
||||||
@@ -700,82 +969,115 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
|
// Peek player content (shows when dragging OR during transition)
|
||||||
|
// Show when: actively dragging (slideOffset != 0) OR in transition state
|
||||||
|
// Must have peek data to render
|
||||||
|
if (t < 0.1 && _peekPlayer != null && (_slideOffset.abs() > 0.01 || _inTransition))
|
||||||
|
_buildPeekContent(
|
||||||
|
maProvider: maProvider,
|
||||||
|
peekPlayer: _peekPlayer,
|
||||||
|
peekImageUrl: _peekImageUrl,
|
||||||
|
slideOffset: _slideOffset,
|
||||||
|
containerWidth: collapsedWidth,
|
||||||
|
artSize: _collapsedArtSize,
|
||||||
|
titleLeft: collapsedTitleLeft,
|
||||||
|
titleTop: collapsedTitleTop,
|
||||||
|
titleWidth: collapsedTitleWidth,
|
||||||
|
artistTop: collapsedArtistTop,
|
||||||
|
titleFontSize: 16.0,
|
||||||
|
artistFontSize: 14.0,
|
||||||
|
textColor: textColor,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
),
|
||||||
|
|
||||||
// Album art - with slide animation when collapsed
|
// Album art - with slide animation when collapsed
|
||||||
|
// Hidden during transition to prevent flash (peek content shows instead)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: artLeft + miniPlayerSlideOffset,
|
left: artLeft + miniPlayerSlideOffset,
|
||||||
top: artTop,
|
top: artTop,
|
||||||
child: Container(
|
child: Opacity(
|
||||||
width: artSize,
|
opacity: _inTransition && t < 0.1 ? 0.0 : 1.0,
|
||||||
height: artSize,
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
width: artSize,
|
||||||
borderRadius: BorderRadius.circular(artBorderRadius),
|
height: artSize,
|
||||||
boxShadow: t > 0.3
|
decoration: BoxDecoration(
|
||||||
? [
|
borderRadius: BorderRadius.circular(artBorderRadius),
|
||||||
BoxShadow(
|
boxShadow: t > 0.3
|
||||||
color: Colors.black.withOpacity(0.3 * t),
|
? [
|
||||||
blurRadius: 24 * t,
|
BoxShadow(
|
||||||
offset: Offset(0, 8 * t),
|
color: Colors.black.withOpacity(0.3 * t),
|
||||||
),
|
blurRadius: 24 * t,
|
||||||
]
|
offset: Offset(0, 8 * t),
|
||||||
: null,
|
),
|
||||||
),
|
]
|
||||||
child: ClipRRect(
|
: null,
|
||||||
borderRadius: BorderRadius.circular(artBorderRadius),
|
),
|
||||||
child: imageUrl != null
|
child: ClipRRect(
|
||||||
? CachedNetworkImage(
|
borderRadius: BorderRadius.circular(artBorderRadius),
|
||||||
imageUrl: imageUrl,
|
child: imageUrl != null
|
||||||
fit: BoxFit.cover,
|
? CachedNetworkImage(
|
||||||
memCacheWidth: t > 0.5 ? 1024 : 256,
|
imageUrl: imageUrl,
|
||||||
memCacheHeight: t > 0.5 ? 1024 : 256,
|
fit: BoxFit.cover,
|
||||||
fadeInDuration: Duration.zero,
|
memCacheWidth: t > 0.5 ? 1024 : 256,
|
||||||
fadeOutDuration: Duration.zero,
|
memCacheHeight: t > 0.5 ? 1024 : 256,
|
||||||
placeholderFadeInDuration: Duration.zero,
|
fadeInDuration: Duration.zero,
|
||||||
placeholder: (_, __) => _buildPlaceholderArt(colorScheme, t),
|
fadeOutDuration: Duration.zero,
|
||||||
errorWidget: (_, __, ___) => _buildPlaceholderArt(colorScheme, t),
|
placeholderFadeInDuration: Duration.zero,
|
||||||
)
|
placeholder: (_, __) => _buildPlaceholderArt(colorScheme, t),
|
||||||
: _buildPlaceholderArt(colorScheme, t),
|
errorWidget: (_, __, ___) => _buildPlaceholderArt(colorScheme, t),
|
||||||
|
)
|
||||||
|
: _buildPlaceholderArt(colorScheme, t),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Track title - with slide animation when collapsed
|
// Track title - with slide animation when collapsed
|
||||||
|
// Hidden during transition to prevent flash
|
||||||
Positioned(
|
Positioned(
|
||||||
left: titleLeft + miniPlayerSlideOffset,
|
left: titleLeft + miniPlayerSlideOffset,
|
||||||
top: titleTop,
|
top: titleTop,
|
||||||
child: SizedBox(
|
child: Opacity(
|
||||||
width: titleWidth,
|
opacity: _inTransition && t < 0.1 ? 0.0 : 1.0,
|
||||||
child: Text(
|
child: SizedBox(
|
||||||
currentTrack.name,
|
width: titleWidth,
|
||||||
style: TextStyle(
|
child: Text(
|
||||||
color: textColor,
|
currentTrack.name,
|
||||||
fontSize: titleFontSize,
|
style: TextStyle(
|
||||||
fontWeight: t > 0.5 ? FontWeight.w600 : FontWeight.w500,
|
color: textColor,
|
||||||
letterSpacing: t > 0.5 ? -0.5 : 0,
|
fontSize: titleFontSize,
|
||||||
height: 1.2,
|
fontWeight: t > 0.5 ? FontWeight.w600 : FontWeight.w500,
|
||||||
|
letterSpacing: t > 0.5 ? -0.5 : 0,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
|
||||||
|
maxLines: t > 0.5 ? 2 : 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
|
|
||||||
maxLines: t > 0.5 ? 2 : 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Artist name - with slide animation when collapsed
|
// Artist name - with slide animation when collapsed
|
||||||
|
// Hidden during transition to prevent flash
|
||||||
Positioned(
|
Positioned(
|
||||||
left: titleLeft + miniPlayerSlideOffset,
|
left: titleLeft + miniPlayerSlideOffset,
|
||||||
top: artistTop,
|
top: artistTop,
|
||||||
child: SizedBox(
|
child: Opacity(
|
||||||
width: titleWidth,
|
opacity: _inTransition && t < 0.1 ? 0.0 : 1.0,
|
||||||
child: Text(
|
child: SizedBox(
|
||||||
currentTrack.artistsString,
|
width: titleWidth,
|
||||||
style: TextStyle(
|
child: Text(
|
||||||
color: textColor.withOpacity(t > 0.5 ? 0.7 : 0.6),
|
currentTrack.artistsString,
|
||||||
fontSize: artistFontSize,
|
style: TextStyle(
|
||||||
fontWeight: t > 0.5 ? FontWeight.w400 : FontWeight.normal,
|
color: textColor.withOpacity(t > 0.5 ? 0.7 : 0.6),
|
||||||
|
fontSize: artistFontSize,
|
||||||
|
fontWeight: t > 0.5 ? FontWeight.w400 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
textAlign: t > 0.5 ? TextAlign.center : TextAlign.left,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -819,37 +1121,40 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
final currentProgress = seekPosition ?? elapsedTime.toDouble();
|
final currentProgress = seekPosition ?? elapsedTime.toDouble();
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
SliderTheme(
|
SizedBox(
|
||||||
data: SliderThemeData(
|
height: 48, // Increase touch target height
|
||||||
trackHeight: 4,
|
child: SliderTheme(
|
||||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
|
data: SliderThemeData(
|
||||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 16),
|
trackHeight: 4,
|
||||||
trackShape: const RoundedRectSliderTrackShape(),
|
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
|
||||||
),
|
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
|
||||||
child: Slider(
|
trackShape: const RoundedRectSliderTrackShape(),
|
||||||
value: currentProgress.clamp(0.0, currentTrack.duration!.inSeconds.toDouble()).toDouble(),
|
),
|
||||||
max: currentTrack.duration!.inSeconds.toDouble(),
|
child: Slider(
|
||||||
onChanged: (value) => _seekPositionNotifier.value = value,
|
value: currentProgress.clamp(0.0, currentTrack.duration!.inSeconds.toDouble()).toDouble(),
|
||||||
onChangeStart: (value) => _seekPositionNotifier.value = value,
|
max: currentTrack.duration!.inSeconds.toDouble(),
|
||||||
onChangeEnd: (value) async {
|
onChanged: (value) => _seekPositionNotifier.value = value,
|
||||||
try {
|
onChangeStart: (value) => _seekPositionNotifier.value = value,
|
||||||
await maProvider.seek(selectedPlayer.playerId, value.round());
|
onChangeEnd: (value) async {
|
||||||
await Future.delayed(const Duration(milliseconds: 200));
|
try {
|
||||||
} catch (e) {
|
await maProvider.seek(selectedPlayer.playerId, value.round());
|
||||||
if (mounted) {
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
} catch (e) {
|
||||||
SnackBar(content: Text('Error seeking: $e')),
|
if (mounted) {
|
||||||
);
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Error seeking: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
_seekPositionNotifier.value = null;
|
||||||
|
_seekPosition = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
},
|
||||||
if (mounted) {
|
activeColor: primaryColor,
|
||||||
_seekPositionNotifier.value = null;
|
inactiveColor: primaryColor.withOpacity(0.2),
|
||||||
_seekPosition = null;
|
),
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
activeColor: primaryColor,
|
|
||||||
inactiveColor: primaryColor.withOpacity(0.2),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
@@ -1056,6 +1361,159 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the peek player content that slides in from the edge during drag
|
||||||
|
Widget _buildPeekContent({
|
||||||
|
required MusicAssistantProvider maProvider,
|
||||||
|
required dynamic peekPlayer,
|
||||||
|
required String? peekImageUrl,
|
||||||
|
required double slideOffset,
|
||||||
|
required double containerWidth,
|
||||||
|
required double artSize,
|
||||||
|
required double titleLeft,
|
||||||
|
required double titleTop,
|
||||||
|
required double titleWidth,
|
||||||
|
required double artistTop,
|
||||||
|
required double titleFontSize,
|
||||||
|
required double artistFontSize,
|
||||||
|
required Color textColor,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
}) {
|
||||||
|
// Calculate sliding position
|
||||||
|
// During transition (_inTransition = true), peek should be at center (offset 0)
|
||||||
|
// When sliding left (negative offset), peek comes from right
|
||||||
|
// When sliding right (positive offset), peek comes from left
|
||||||
|
|
||||||
|
double peekBaseOffset;
|
||||||
|
|
||||||
|
if (_inTransition && slideOffset.abs() < 0.01) {
|
||||||
|
// During transition with slideOffset at 0, show peek at center
|
||||||
|
peekBaseOffset = 0.0;
|
||||||
|
} else {
|
||||||
|
final isFromRight = slideOffset < 0;
|
||||||
|
final peekProgress = slideOffset.abs();
|
||||||
|
|
||||||
|
// Position calculation:
|
||||||
|
// From right: starts at containerWidth (off right edge), moves to 0 as progress increases
|
||||||
|
// From left: starts at -containerWidth (off left edge), moves to 0 as progress increases
|
||||||
|
peekBaseOffset = isFromRight
|
||||||
|
? containerWidth * (1 - peekProgress) // Slides in from right
|
||||||
|
: -containerWidth * (1 - peekProgress); // Slides in from left
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if peek player has a track - if not, show device info instead
|
||||||
|
final hasTrack = _peekTrack != null && peekImageUrl != null;
|
||||||
|
final peekTrackName = hasTrack ? _peekTrack!.name : (peekPlayer?.name ?? 'Unknown');
|
||||||
|
final peekArtistName = hasTrack ? (_peekTrack!.artistsString ?? '') : 'Swipe to switch device';
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
// Peek album art / device icon
|
||||||
|
Positioned(
|
||||||
|
left: peekBaseOffset,
|
||||||
|
top: 0,
|
||||||
|
child: Container(
|
||||||
|
width: artSize,
|
||||||
|
height: artSize,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
child: hasTrack
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: peekImageUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 256,
|
||||||
|
memCacheHeight: 256,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholderFadeInDuration: Duration.zero,
|
||||||
|
placeholder: (_, __) => _buildMiniPlaceholderArt(colorScheme),
|
||||||
|
errorWidget: (_, __, ___) => _buildMiniPlaceholderArt(colorScheme),
|
||||||
|
)
|
||||||
|
: _buildDeviceIcon(peekPlayer?.name ?? '', artSize, colorScheme),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Peek track title / device name
|
||||||
|
Positioned(
|
||||||
|
left: titleLeft + peekBaseOffset,
|
||||||
|
top: titleTop,
|
||||||
|
child: SizedBox(
|
||||||
|
width: titleWidth,
|
||||||
|
child: Text(
|
||||||
|
peekTrackName,
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor,
|
||||||
|
fontSize: titleFontSize,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Peek artist name / device hint
|
||||||
|
Positioned(
|
||||||
|
left: titleLeft + peekBaseOffset,
|
||||||
|
top: artistTop,
|
||||||
|
child: SizedBox(
|
||||||
|
width: titleWidth,
|
||||||
|
child: Text(
|
||||||
|
peekArtistName,
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor.withOpacity(0.6),
|
||||||
|
fontSize: artistFontSize,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build device icon for non-playing peek player
|
||||||
|
Widget _buildDeviceIcon(String playerName, double size, ColorScheme colorScheme) {
|
||||||
|
final nameLower = playerName.toLowerCase();
|
||||||
|
IconData icon;
|
||||||
|
if (nameLower.contains('phone') || nameLower.contains('ensemble')) {
|
||||||
|
icon = Icons.phone_android_rounded;
|
||||||
|
} else if (nameLower.contains('group') || nameLower.contains('all')) {
|
||||||
|
icon = Icons.speaker_group_rounded;
|
||||||
|
} else if (nameLower.contains('tv') || nameLower.contains('television')) {
|
||||||
|
icon = Icons.tv_rounded;
|
||||||
|
} else if (nameLower.contains('cast') || nameLower.contains('chromecast')) {
|
||||||
|
icon = Icons.cast_rounded;
|
||||||
|
} else {
|
||||||
|
icon = Icons.speaker_rounded;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
size: size * 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simplified placeholder for mini player peek
|
||||||
|
Widget _buildMiniPlaceholderArt(ColorScheme colorScheme) {
|
||||||
|
return Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Icons.music_note_rounded,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildPlaceholderArt(ColorScheme colorScheme, double t) {
|
Widget _buildPlaceholderArt(ColorScheme colorScheme, double t) {
|
||||||
return Container(
|
return Container(
|
||||||
color: Color.lerp(colorScheme.surfaceVariant, const Color(0xFF2a2a2a), t),
|
color: Color.lerp(colorScheme.surfaceVariant, const Color(0xFF2a2a2a), t),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import '../../theme/design_tokens.dart';
|
|||||||
/// A compact device selector bar shown when no track is playing
|
/// A compact device selector bar shown when no track is playing
|
||||||
class DeviceSelectorBar extends StatelessWidget {
|
class DeviceSelectorBar extends StatelessWidget {
|
||||||
final dynamic selectedPlayer;
|
final dynamic selectedPlayer;
|
||||||
|
final dynamic peekPlayer;
|
||||||
final bool hasMultiplePlayers;
|
final bool hasMultiplePlayers;
|
||||||
final Color backgroundColor;
|
final Color backgroundColor;
|
||||||
final Color textColor;
|
final Color textColor;
|
||||||
@@ -11,11 +12,14 @@ class DeviceSelectorBar extends StatelessWidget {
|
|||||||
final double height;
|
final double height;
|
||||||
final double borderRadius;
|
final double borderRadius;
|
||||||
final double slideOffset;
|
final double slideOffset;
|
||||||
|
final GestureDragStartCallback? onHorizontalDragStart;
|
||||||
|
final GestureDragUpdateCallback? onHorizontalDragUpdate;
|
||||||
final GestureDragEndCallback? onHorizontalDragEnd;
|
final GestureDragEndCallback? onHorizontalDragEnd;
|
||||||
|
|
||||||
const DeviceSelectorBar({
|
const DeviceSelectorBar({
|
||||||
super.key,
|
super.key,
|
||||||
required this.selectedPlayer,
|
required this.selectedPlayer,
|
||||||
|
this.peekPlayer,
|
||||||
required this.hasMultiplePlayers,
|
required this.hasMultiplePlayers,
|
||||||
required this.backgroundColor,
|
required this.backgroundColor,
|
||||||
required this.textColor,
|
required this.textColor,
|
||||||
@@ -23,12 +27,16 @@ class DeviceSelectorBar extends StatelessWidget {
|
|||||||
required this.height,
|
required this.height,
|
||||||
required this.borderRadius,
|
required this.borderRadius,
|
||||||
required this.slideOffset,
|
required this.slideOffset,
|
||||||
|
this.onHorizontalDragStart,
|
||||||
|
this.onHorizontalDragUpdate,
|
||||||
this.onHorizontalDragEnd,
|
this.onHorizontalDragEnd,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
|
onHorizontalDragStart: hasMultiplePlayers ? onHorizontalDragStart : null,
|
||||||
|
onHorizontalDragUpdate: hasMultiplePlayers ? onHorizontalDragUpdate : null,
|
||||||
onHorizontalDragEnd: hasMultiplePlayers ? onHorizontalDragEnd : null,
|
onHorizontalDragEnd: hasMultiplePlayers ? onHorizontalDragEnd : null,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: backgroundColor,
|
color: backgroundColor,
|
||||||
@@ -40,47 +48,56 @@ class DeviceSelectorBar extends StatelessWidget {
|
|||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
child: ClipRect(
|
child: ClipRect(
|
||||||
child: Transform.translate(
|
child: Stack(
|
||||||
offset: Offset(slideOffset * width, 0),
|
children: [
|
||||||
child: Padding(
|
// Peek player content (shows when dragging)
|
||||||
padding: Spacing.paddingH16,
|
if (slideOffset.abs() > 0.01 && peekPlayer != null)
|
||||||
child: Row(
|
_buildPeekContent(),
|
||||||
children: [
|
|
||||||
Icon(
|
// Current player content
|
||||||
_getPlayerIcon(selectedPlayer.name),
|
Transform.translate(
|
||||||
color: textColor,
|
offset: Offset(slideOffset * width, 0),
|
||||||
size: IconSizes.md,
|
child: Padding(
|
||||||
),
|
padding: Spacing.paddingH16,
|
||||||
Spacing.hGap12,
|
child: Row(
|
||||||
Expanded(
|
children: [
|
||||||
child: Column(
|
Icon(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
_getPlayerIcon(selectedPlayer.name),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
color: textColor,
|
||||||
children: [
|
size: IconSizes.md,
|
||||||
Text(
|
),
|
||||||
selectedPlayer.name,
|
Spacing.hGap12,
|
||||||
style: TextStyle(
|
Expanded(
|
||||||
color: textColor,
|
child: Column(
|
||||||
fontSize: 16,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
fontWeight: FontWeight.w500,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
),
|
children: [
|
||||||
maxLines: 1,
|
Text(
|
||||||
overflow: TextOverflow.ellipsis,
|
selectedPlayer.name,
|
||||||
),
|
style: TextStyle(
|
||||||
if (hasMultiplePlayers)
|
color: textColor,
|
||||||
Text(
|
fontSize: 16,
|
||||||
'Swipe to switch device',
|
fontWeight: FontWeight.w500,
|
||||||
style: TextStyle(
|
),
|
||||||
color: textColor.withOpacity(0.6),
|
maxLines: 1,
|
||||||
fontSize: 12,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
if (hasMultiplePlayers)
|
||||||
],
|
Text(
|
||||||
),
|
'Swipe to switch device',
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor.withOpacity(0.6),
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -88,6 +105,60 @@ class DeviceSelectorBar extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build peek player content that slides in from the edge
|
||||||
|
Widget _buildPeekContent() {
|
||||||
|
final isFromRight = slideOffset < 0;
|
||||||
|
final peekProgress = slideOffset.abs();
|
||||||
|
|
||||||
|
// Calculate peek position - slides in as main content slides out
|
||||||
|
final peekBaseOffset = isFromRight
|
||||||
|
? width * (1 - peekProgress)
|
||||||
|
: -width * (1 - peekProgress);
|
||||||
|
|
||||||
|
return Transform.translate(
|
||||||
|
offset: Offset(peekBaseOffset, 0),
|
||||||
|
child: Padding(
|
||||||
|
padding: Spacing.paddingH16,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_getPlayerIcon(peekPlayer.name),
|
||||||
|
color: textColor,
|
||||||
|
size: IconSizes.md,
|
||||||
|
),
|
||||||
|
Spacing.hGap12,
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
peekPlayer.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (hasMultiplePlayers)
|
||||||
|
Text(
|
||||||
|
'Swipe to switch device',
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor.withOpacity(0.6),
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Get appropriate icon for player based on name
|
/// Get appropriate icon for player based on name
|
||||||
IconData _getPlayerIcon(String playerName) {
|
IconData _getPlayerIcon(String playerName) {
|
||||||
final nameLower = playerName.toLowerCase();
|
final nameLower = playerName.toLowerCase();
|
||||||
|
|||||||
@@ -109,17 +109,19 @@ class _VolumeControlState extends State<VolumeControl> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SliderTheme(
|
child: SizedBox(
|
||||||
data: SliderThemeData(
|
height: 48, // Increase touch target height
|
||||||
trackHeight: 2,
|
child: SliderTheme(
|
||||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
data: SliderThemeData(
|
||||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
|
trackHeight: 2,
|
||||||
activeTrackColor: Colors.white,
|
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||||
inactiveTrackColor: Colors.white.withOpacity(0.3),
|
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
|
||||||
thumbColor: Colors.white,
|
activeTrackColor: Colors.white,
|
||||||
overlayColor: Colors.white.withOpacity(0.2),
|
inactiveTrackColor: Colors.white.withOpacity(0.3),
|
||||||
),
|
thumbColor: Colors.white,
|
||||||
child: Slider(
|
overlayColor: Colors.white.withOpacity(0.2),
|
||||||
|
),
|
||||||
|
child: Slider(
|
||||||
value: currentVolume.clamp(0.0, 1.0),
|
value: currentVolume.clamp(0.0, 1.0),
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -163,6 +165,7 @@ class _VolumeControlState extends State<VolumeControl> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
|
|||||||
Reference in New Issue
Block a user