Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5109f8ce8c | ||
|
|
71938034b6 | ||
|
|
aaf2c7c91f | ||
|
|
15c634290e | ||
|
|
a4790ad054 | ||
|
|
cff1c92ce2 | ||
|
|
26356514ca | ||
|
|
31801d261d | ||
|
|
ed4d876dee | ||
|
|
575b7520d6 |
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<p>A minimalistic mobile client for <a href="https://music-assistant.io/">Music Assistant</a></p>
|
<p><strong>An unofficial mobile client for Music Assistant</strong></p>
|
||||||
<p>Stream your music library directly to your phone, or control playback on any connected speaker.</p>
|
<p>Stream your music library directly to your phone, or control playback on any connected speaker.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
|
**Ensemble is an unofficial, community-built mobile client for Music Assistant. It is not affiliated with, endorsed by, or supported by the Music Assistant project or its developers.**
|
||||||
|
|
||||||
This application was built with AI-assisted development using **Claude Code** and **Gemini CLI**.
|
This application was built with AI-assisted development using **Claude Code** and **Gemini CLI**.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -98,16 +100,6 @@ Ensemble supports multiple authentication methods:
|
|||||||
|
|
||||||
> **Note:** Local device playback (streaming to your phone) is not currently supported on Music Assistant beta 20+. This is due to MA replacing the built-in player with Sendspin. Support will be added once Sendspin is more stable. Remote control of other players works fine.
|
> **Note:** Local device playback (streaming to your phone) is not currently supported on Music Assistant beta 20+. This is due to MA replacing the built-in player with Sendspin. Support will be added once Sendspin is more stable. Remote control of other players works fine.
|
||||||
|
|
||||||
## About Music Assistant
|
|
||||||
|
|
||||||
Ensemble is a mobile client for [Music Assistant](https://music-assistant.io/), an open-source music library manager that integrates with various music sources and streaming providers.
|
|
||||||
|
|
||||||
With Ensemble, you can:
|
|
||||||
- **Stream locally** - Use your phone as a Music Assistant player
|
|
||||||
- **Control remotely** - Manage playback on smart speakers, Chromecast, Sonos, and more
|
|
||||||
|
|
||||||
Learn more: [music-assistant.io](https://music-assistant.io/)
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
|
|||||||
@@ -545,6 +545,47 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Show fullscreen album art overlay
|
||||||
|
void _showFullscreenArt(String? imageUrl) {
|
||||||
|
if (imageUrl == null) return;
|
||||||
|
|
||||||
|
Navigator.of(context).push(
|
||||||
|
PageRouteBuilder(
|
||||||
|
opaque: false,
|
||||||
|
barrierColor: Colors.black87,
|
||||||
|
barrierDismissible: true,
|
||||||
|
pageBuilder: (context, animation, secondaryAnimation) {
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => Navigator.of(context).pop(),
|
||||||
|
onVerticalDragEnd: (details) {
|
||||||
|
if (details.primaryVelocity != null && details.primaryVelocity!.abs() > 300) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
body: Center(
|
||||||
|
child: InteractiveViewer(
|
||||||
|
minScale: 0.5,
|
||||||
|
maxScale: 3.0,
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
memCacheWidth: 1024,
|
||||||
|
memCacheHeight: 1024,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
|
// CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
|
||||||
@@ -607,35 +648,38 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 60),
|
const SizedBox(height: 60),
|
||||||
Hero(
|
GestureDetector(
|
||||||
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
|
onTap: () => _showFullscreenArt(imageUrl),
|
||||||
child: Container(
|
child: Hero(
|
||||||
width: 280, // Increased size
|
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
|
||||||
height: 280,
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
width: 280, // Increased size
|
||||||
color: colorScheme.surfaceVariant,
|
height: 280,
|
||||||
borderRadius: BorderRadius.circular(16), // Slightly more rounded
|
decoration: BoxDecoration(
|
||||||
boxShadow: [
|
color: colorScheme.surfaceVariant,
|
||||||
BoxShadow(
|
borderRadius: BorderRadius.circular(16), // Slightly more rounded
|
||||||
color: Colors.black.withOpacity(0.3),
|
boxShadow: [
|
||||||
blurRadius: 20,
|
BoxShadow(
|
||||||
offset: const Offset(0, 10),
|
color: Colors.black.withOpacity(0.3),
|
||||||
),
|
blurRadius: 20,
|
||||||
],
|
offset: const Offset(0, 10),
|
||||||
image: imageUrl != null
|
),
|
||||||
? DecorationImage(
|
],
|
||||||
image: CachedNetworkImageProvider(imageUrl),
|
image: imageUrl != null
|
||||||
fit: BoxFit.cover,
|
? DecorationImage(
|
||||||
|
image: CachedNetworkImageProvider(imageUrl),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: imageUrl == null
|
||||||
|
? Icon(
|
||||||
|
Icons.album_rounded,
|
||||||
|
size: 120,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: imageUrl == null
|
|
||||||
? Icon(
|
|
||||||
Icons.album_rounded,
|
|
||||||
size: 120,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -960,7 +1004,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
childCount: _tracks.length,
|
childCount: _tracks.length,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 140)), // Space for bottom nav + mini player
|
const SliverToBoxAdapter(child: SizedBox(height: 164)), // Space for bottom nav + mini player
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -326,27 +326,69 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (_isCurrentTrackFavorite) {
|
if (_isCurrentTrackFavorite) {
|
||||||
// Remove from favorites
|
// Remove from favorites - need library_item_id (numeric)
|
||||||
final libraryItemId = currentTrack.itemId;
|
int? libraryItemId;
|
||||||
if (libraryItemId is int) {
|
|
||||||
await maProvider.api!.removeFromFavorites('track', libraryItemId);
|
if (currentTrack.provider == 'library') {
|
||||||
} else if (libraryItemId is String) {
|
// If provider is library, itemId is the library ID
|
||||||
final parsed = int.tryParse(libraryItemId);
|
libraryItemId = int.tryParse(currentTrack.itemId.toString());
|
||||||
if (parsed != null) {
|
} else if (currentTrack.providerMappings != null) {
|
||||||
await maProvider.api!.removeFromFavorites('track', parsed);
|
// Find the library mapping to get the library ID
|
||||||
|
final mappings = currentTrack.providerMappings as List<dynamic>?;
|
||||||
|
if (mappings != null) {
|
||||||
|
for (final mapping in mappings) {
|
||||||
|
if (mapping.providerInstance == 'library') {
|
||||||
|
libraryItemId = int.tryParse(mapping.itemId.toString());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (libraryItemId != null) {
|
||||||
|
await maProvider.api!.removeFromFavorites('track', libraryItemId);
|
||||||
|
} else {
|
||||||
|
throw Exception('Could not determine library ID for this track');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Add to favorites
|
// Add to favorites - use the source provider (not library)
|
||||||
final provider = currentTrack.provider as String? ?? 'library';
|
String actualProvider = currentTrack.provider?.toString() ?? 'library';
|
||||||
final itemId = currentTrack.itemId?.toString() ?? '';
|
String actualItemId = currentTrack.itemId?.toString() ?? '';
|
||||||
await maProvider.api!.addToFavorites('track', itemId, provider);
|
|
||||||
|
if (currentTrack.providerMappings != null) {
|
||||||
|
final mappings = currentTrack.providerMappings as List<dynamic>?;
|
||||||
|
if (mappings != null && mappings.isNotEmpty) {
|
||||||
|
// Find a non-library provider mapping
|
||||||
|
for (final mapping in mappings) {
|
||||||
|
if (mapping.available == true && mapping.providerInstance != 'library') {
|
||||||
|
actualProvider = mapping.providerInstance;
|
||||||
|
actualItemId = mapping.itemId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to first available if no non-library found
|
||||||
|
if (actualProvider == 'library') {
|
||||||
|
for (final mapping in mappings) {
|
||||||
|
if (mapping.available == true) {
|
||||||
|
actualProvider = mapping.providerInstance;
|
||||||
|
actualItemId = mapping.itemId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await maProvider.api!.addToFavorites('track', actualItemId, actualProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle local state
|
// Toggle local state
|
||||||
setState(() {
|
setState(() {
|
||||||
_isCurrentTrackFavorite = !_isCurrentTrackFavorite;
|
_isCurrentTrackFavorite = !_isCurrentTrackFavorite;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Invalidate home cache so favorites are updated
|
||||||
|
maProvider.invalidateHomeCache();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Show error feedback
|
// Show error feedback
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -360,6 +402,50 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Show fullscreen album art overlay
|
||||||
|
void _showFullscreenArt(BuildContext context, String? imageUrl) {
|
||||||
|
if (imageUrl == null) return;
|
||||||
|
|
||||||
|
Navigator.of(context).push(
|
||||||
|
PageRouteBuilder(
|
||||||
|
opaque: false,
|
||||||
|
barrierColor: Colors.black87,
|
||||||
|
barrierDismissible: true,
|
||||||
|
pageBuilder: (context, animation, secondaryAnimation) {
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => Navigator.of(context).pop(),
|
||||||
|
onVerticalDragEnd: (details) {
|
||||||
|
if (details.primaryVelocity != null && details.primaryVelocity!.abs() > 300) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
body: Center(
|
||||||
|
child: Hero(
|
||||||
|
tag: 'fullscreen_album_art',
|
||||||
|
child: InteractiveViewer(
|
||||||
|
minScale: 0.5,
|
||||||
|
maxScale: 3.0,
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
memCacheWidth: 1024,
|
||||||
|
memCacheHeight: 1024,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Get available players sorted alphabetically (consistent with device selector)
|
/// Get available players sorted alphabetically (consistent with device selector)
|
||||||
List<dynamic> _getAvailablePlayersSorted(MusicAssistantProvider maProvider) {
|
List<dynamic> _getAvailablePlayersSorted(MusicAssistantProvider maProvider) {
|
||||||
// Use the provider's already-sorted list, filter for available only
|
// Use the provider's already-sorted list, filter for available only
|
||||||
@@ -502,11 +588,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
/// Animate committing to the next/previous player
|
/// Animate committing to the next/previous player
|
||||||
void _animateCommit(int direction, VoidCallback onSwitch) {
|
void _animateCommit(int direction, VoidCallback onSwitch) {
|
||||||
if (_isSliding) return;
|
if (_isSliding) return;
|
||||||
_isSliding = true;
|
|
||||||
|
|
||||||
// Mark transition BEFORE animation - this keeps peek content visible
|
// Mark transition state in setState to ensure rebuilds happen properly
|
||||||
// and hides main content to prevent any flash
|
setState(() {
|
||||||
_inTransition = true;
|
_isSliding = true;
|
||||||
|
// Mark transition BEFORE animation - this keeps peek content visible
|
||||||
|
// and hides main content to prevent any flash
|
||||||
|
_inTransition = true;
|
||||||
|
});
|
||||||
|
|
||||||
final startOffset = _slideOffset;
|
final startOffset = _slideOffset;
|
||||||
final targetOffset = direction < 0 ? -1.0 : 1.0;
|
final targetOffset = direction < 0 ? -1.0 : 1.0;
|
||||||
@@ -830,6 +919,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
final collapsedBg = themeProvider.adaptiveTheme && adaptiveScheme != null
|
final collapsedBg = themeProvider.adaptiveTheme && adaptiveScheme != null
|
||||||
? adaptiveScheme.primaryContainer
|
? adaptiveScheme.primaryContainer
|
||||||
: colorScheme.primaryContainer;
|
: colorScheme.primaryContainer;
|
||||||
|
// Create a darker shade for the "unplayed" portion of progress bar
|
||||||
|
final collapsedBgUnplayed = Color.lerp(collapsedBg, Colors.black, 0.3)!;
|
||||||
final expandedBg = adaptiveScheme?.surface ?? const Color(0xFF121212);
|
final expandedBg = adaptiveScheme?.surface ?? const Color(0xFF121212);
|
||||||
// Only update if we have adaptive colors, otherwise keep previous value
|
// Only update if we have adaptive colors, otherwise keep previous value
|
||||||
if (adaptiveScheme != null) {
|
if (adaptiveScheme != null) {
|
||||||
@@ -837,7 +928,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
} else if (_currentExpandedBgColor == null) {
|
} else if (_currentExpandedBgColor == null) {
|
||||||
_currentExpandedBgColor = expandedBg; // First time fallback
|
_currentExpandedBgColor = expandedBg; // First time fallback
|
||||||
}
|
}
|
||||||
final backgroundColor = Color.lerp(collapsedBg, expandedBg, t)!;
|
// When collapsed, use the darker unplayed color as base (progress bar will overlay the played portion)
|
||||||
|
// When expanded, transition to the normal background
|
||||||
|
final backgroundColor = Color.lerp(t < 0.5 ? collapsedBgUnplayed : collapsedBg, expandedBg, t)!;
|
||||||
|
|
||||||
final collapsedTextColor = themeProvider.adaptiveTheme && adaptiveScheme != null
|
final collapsedTextColor = themeProvider.adaptiveTheme && adaptiveScheme != null
|
||||||
? adaptiveScheme.onPrimaryContainer
|
? adaptiveScheme.onPrimaryContainer
|
||||||
@@ -1008,9 +1101,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
// Use translucent to allow child widgets (like buttons) to receive taps
|
// Use translucent to allow child widgets (like buttons) to receive taps
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
onTap: () {
|
// Only handle tap when collapsed - when expanded, let children handle their own taps
|
||||||
if (!isExpanded) expand();
|
onTap: isExpanded ? null : expand,
|
||||||
},
|
|
||||||
onVerticalDragUpdate: (details) {
|
onVerticalDragUpdate: (details) {
|
||||||
if (details.primaryDelta! < -10 && !isExpanded) {
|
if (details.primaryDelta! < -10 && !isExpanded) {
|
||||||
expand();
|
expand();
|
||||||
@@ -1070,6 +1162,46 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
|
// Mini player progress bar background (collapsed only)
|
||||||
|
// Shows played portion with brighter color, unplayed with darker
|
||||||
|
// Starts after album art so progress is visible
|
||||||
|
// Slides with content during swipe, hidden during transition if peek available
|
||||||
|
if (t < 0.5 && currentTrack?.duration != null && !(_inTransition && t < 0.1 && _peekPlayer != null))
|
||||||
|
Positioned(
|
||||||
|
left: _collapsedArtSize + miniPlayerSlideOffset,
|
||||||
|
top: 0,
|
||||||
|
width: width - _collapsedArtSize,
|
||||||
|
bottom: 0,
|
||||||
|
child: ValueListenableBuilder<int>(
|
||||||
|
valueListenable: _progressNotifier,
|
||||||
|
builder: (context, elapsedSeconds, _) {
|
||||||
|
final totalSeconds = currentTrack!.duration!.inSeconds;
|
||||||
|
if (totalSeconds <= 0) return const SizedBox.shrink();
|
||||||
|
final progress = (elapsedSeconds / totalSeconds).clamp(0.0, 1.0);
|
||||||
|
// Fade out as we expand
|
||||||
|
final progressOpacity = (1.0 - (t / 0.5)).clamp(0.0, 1.0);
|
||||||
|
final progressAreaWidth = width - _collapsedArtSize;
|
||||||
|
return Opacity(
|
||||||
|
opacity: progressOpacity,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topRight: Radius.circular(borderRadius),
|
||||||
|
bottomRight: Radius.circular(borderRadius),
|
||||||
|
),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
width: progressAreaWidth * progress,
|
||||||
|
height: height,
|
||||||
|
color: collapsedBg,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Peek player content (shows when dragging OR during transition)
|
// Peek player content (shows when dragging OR during transition)
|
||||||
// Show when: actively dragging (slideOffset != 0) OR in transition state
|
// Show when: actively dragging (slideOffset != 0) OR in transition state
|
||||||
// Must have peek data to render
|
// Must have peek data to render
|
||||||
@@ -1093,45 +1225,53 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
|
|
||||||
// Album art - with slide animation when collapsed
|
// Album art - with slide animation when collapsed
|
||||||
// Hidden during transition to prevent flash (peek content shows instead)
|
// Hidden during transition to prevent flash (peek content shows instead)
|
||||||
|
// FALLBACK: Show main content if transition is active but peek content unavailable
|
||||||
|
// This prevents showing only the progress bar with no content
|
||||||
// GPU PERF: Use conditional instead of Opacity to avoid saveLayer
|
// GPU PERF: Use conditional instead of Opacity to avoid saveLayer
|
||||||
if (!(_inTransition && t < 0.1))
|
if (!(_inTransition && t < 0.1 && _peekPlayer != null))
|
||||||
Positioned(
|
Positioned(
|
||||||
left: artLeft + miniPlayerSlideOffset,
|
left: artLeft + miniPlayerSlideOffset,
|
||||||
top: artTop,
|
top: artTop,
|
||||||
child: Container(
|
child: GestureDetector(
|
||||||
width: artSize,
|
behavior: HitTestBehavior.opaque,
|
||||||
height: artSize,
|
// Use onTapUp instead of onTap - resolves immediately and wins
|
||||||
decoration: BoxDecoration(
|
// the gesture arena against the outer vertical drag detector
|
||||||
borderRadius: BorderRadius.circular(artBorderRadius),
|
onTapUp: t > 0.5 ? (_) => _showFullscreenArt(context, imageUrl) : null,
|
||||||
// GPU PERF: Fixed blur/offset, only animate shadow opacity
|
child: Container(
|
||||||
boxShadow: t > 0.3
|
width: artSize,
|
||||||
? [
|
height: artSize,
|
||||||
BoxShadow(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black.withOpacity(0.25 * ((t - 0.3) / 0.7).clamp(0.0, 1.0)),
|
|
||||||
blurRadius: 20,
|
|
||||||
offset: const Offset(0, 8),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
// Use RepaintBoundary to isolate art repaints during animation
|
|
||||||
child: RepaintBoundary(
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(artBorderRadius),
|
borderRadius: BorderRadius.circular(artBorderRadius),
|
||||||
child: imageUrl != null
|
// GPU PERF: Fixed blur/offset, only animate shadow opacity
|
||||||
? CachedNetworkImage(
|
boxShadow: t > 0.3
|
||||||
imageUrl: imageUrl,
|
? [
|
||||||
fit: BoxFit.cover,
|
BoxShadow(
|
||||||
// Fixed cache size to avoid mid-animation cache thrashing
|
color: Colors.black.withOpacity(0.25 * ((t - 0.3) / 0.7).clamp(0.0, 1.0)),
|
||||||
memCacheWidth: 512,
|
blurRadius: 20,
|
||||||
memCacheHeight: 512,
|
offset: const Offset(0, 8),
|
||||||
fadeInDuration: Duration.zero,
|
),
|
||||||
fadeOutDuration: Duration.zero,
|
]
|
||||||
placeholderFadeInDuration: Duration.zero,
|
: null,
|
||||||
placeholder: (_, __) => _buildPlaceholderArt(colorScheme, t),
|
),
|
||||||
errorWidget: (_, __, ___) => _buildPlaceholderArt(colorScheme, t),
|
// Use RepaintBoundary to isolate art repaints during animation
|
||||||
)
|
child: RepaintBoundary(
|
||||||
: _buildPlaceholderArt(colorScheme, t),
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(artBorderRadius),
|
||||||
|
child: imageUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
// Fixed cache size to avoid mid-animation cache thrashing
|
||||||
|
memCacheWidth: 512,
|
||||||
|
memCacheHeight: 512,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholderFadeInDuration: Duration.zero,
|
||||||
|
placeholder: (_, __) => _buildPlaceholderArt(colorScheme, t),
|
||||||
|
errorWidget: (_, __, ___) => _buildPlaceholderArt(colorScheme, t),
|
||||||
|
)
|
||||||
|
: _buildPlaceholderArt(colorScheme, t),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1139,8 +1279,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
|
|
||||||
// Track title - with slide animation when collapsed
|
// Track title - with slide animation when collapsed
|
||||||
// Hidden during transition to prevent flash
|
// Hidden during transition to prevent flash
|
||||||
|
// FALLBACK: Show if transition active but no peek content available
|
||||||
// GPU PERF: Use conditional instead of Opacity to avoid saveLayer
|
// GPU PERF: Use conditional instead of Opacity to avoid saveLayer
|
||||||
if (!(_inTransition && t < 0.1))
|
if (!(_inTransition && t < 0.1 && _peekPlayer != null))
|
||||||
Positioned(
|
Positioned(
|
||||||
left: titleLeft + miniPlayerSlideOffset,
|
left: titleLeft + miniPlayerSlideOffset,
|
||||||
top: titleTop,
|
top: titleTop,
|
||||||
@@ -1164,8 +1305,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
|
|
||||||
// Artist name - with slide animation when collapsed
|
// Artist name - with slide animation when collapsed
|
||||||
// Hidden during transition to prevent flash
|
// Hidden during transition to prevent flash
|
||||||
|
// FALLBACK: Show if transition active but no peek content available
|
||||||
// GPU PERF: Use conditional instead of Opacity to avoid saveLayer
|
// GPU PERF: Use conditional instead of Opacity to avoid saveLayer
|
||||||
if (!(_inTransition && t < 0.1))
|
if (!(_inTransition && t < 0.1 && _peekPlayer != null))
|
||||||
Positioned(
|
Positioned(
|
||||||
left: titleLeft + miniPlayerSlideOffset,
|
left: titleLeft + miniPlayerSlideOffset,
|
||||||
top: artistTop,
|
top: artistTop,
|
||||||
@@ -1642,6 +1784,56 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build peek player progress bar that slides in during swipe
|
||||||
|
Widget _buildPeekProgressBar({
|
||||||
|
required double slideOffset,
|
||||||
|
required bool inTransition,
|
||||||
|
required double collapsedWidth,
|
||||||
|
required double progressAreaWidth,
|
||||||
|
required double height,
|
||||||
|
required double borderRadius,
|
||||||
|
required Color color,
|
||||||
|
}) {
|
||||||
|
// Calculate peek progress bar position (mirrors _buildPeekContent positioning)
|
||||||
|
double peekProgressLeft;
|
||||||
|
if (inTransition && slideOffset.abs() < 0.01) {
|
||||||
|
peekProgressLeft = _collapsedArtSize;
|
||||||
|
} else {
|
||||||
|
final isFromRight = slideOffset < 0;
|
||||||
|
final peekProgress = slideOffset.abs();
|
||||||
|
peekProgressLeft = _collapsedArtSize + (isFromRight
|
||||||
|
? collapsedWidth * (1 - peekProgress)
|
||||||
|
: -collapsedWidth * (1 - peekProgress));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get peek player's elapsed time - safely handle null/invalid values
|
||||||
|
final peekElapsed = _peekPlayer?.currentElapsed ?? 0.0;
|
||||||
|
final peekTotal = _peekTrack?.duration?.inSeconds ?? 0;
|
||||||
|
if (peekTotal <= 0) return const SizedBox.shrink();
|
||||||
|
final peekProgressValue = (peekElapsed / peekTotal).clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
left: peekProgressLeft,
|
||||||
|
top: 0,
|
||||||
|
width: progressAreaWidth,
|
||||||
|
bottom: 0,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topRight: Radius.circular(borderRadius),
|
||||||
|
bottomRight: Radius.circular(borderRadius),
|
||||||
|
),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
width: progressAreaWidth * peekProgressValue,
|
||||||
|
height: height,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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),
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: ensemble
|
name: ensemble
|
||||||
description: A minimalistic Music Assistant client built with Flutter
|
description: A minimalistic Music Assistant client built with Flutter
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.9.2+13
|
version: 1.9.3+14
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|||||||
Reference in New Issue
Block a user