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>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
## 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**.
|
||||
|
||||
---
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
// CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
|
||||
@@ -607,35 +648,38 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 60),
|
||||
Hero(
|
||||
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
|
||||
child: Container(
|
||||
width: 280, // Increased size
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(16), // Slightly more rounded
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
image: imageUrl != null
|
||||
? DecorationImage(
|
||||
image: CachedNetworkImageProvider(imageUrl),
|
||||
fit: BoxFit.cover,
|
||||
GestureDetector(
|
||||
onTap: () => _showFullscreenArt(imageUrl),
|
||||
child: Hero(
|
||||
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
|
||||
child: Container(
|
||||
width: 280, // Increased size
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(16), // Slightly more rounded
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
image: imageUrl != null
|
||||
? DecorationImage(
|
||||
image: CachedNetworkImageProvider(imageUrl),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: imageUrl == null
|
||||
? Icon(
|
||||
Icons.album_rounded,
|
||||
size: 120,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
)
|
||||
: 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,
|
||||
),
|
||||
),
|
||||
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 {
|
||||
if (_isCurrentTrackFavorite) {
|
||||
// Remove from favorites
|
||||
final libraryItemId = currentTrack.itemId;
|
||||
if (libraryItemId is int) {
|
||||
await maProvider.api!.removeFromFavorites('track', libraryItemId);
|
||||
} else if (libraryItemId is String) {
|
||||
final parsed = int.tryParse(libraryItemId);
|
||||
if (parsed != null) {
|
||||
await maProvider.api!.removeFromFavorites('track', parsed);
|
||||
// Remove from favorites - need library_item_id (numeric)
|
||||
int? libraryItemId;
|
||||
|
||||
if (currentTrack.provider == 'library') {
|
||||
// If provider is library, itemId is the library ID
|
||||
libraryItemId = int.tryParse(currentTrack.itemId.toString());
|
||||
} else if (currentTrack.providerMappings != null) {
|
||||
// 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 {
|
||||
// Add to favorites
|
||||
final provider = currentTrack.provider as String? ?? 'library';
|
||||
final itemId = currentTrack.itemId?.toString() ?? '';
|
||||
await maProvider.api!.addToFavorites('track', itemId, provider);
|
||||
// Add to favorites - use the source provider (not library)
|
||||
String actualProvider = currentTrack.provider?.toString() ?? 'library';
|
||||
String actualItemId = currentTrack.itemId?.toString() ?? '';
|
||||
|
||||
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
|
||||
setState(() {
|
||||
_isCurrentTrackFavorite = !_isCurrentTrackFavorite;
|
||||
});
|
||||
|
||||
// Invalidate home cache so favorites are updated
|
||||
maProvider.invalidateHomeCache();
|
||||
} catch (e) {
|
||||
// Show error feedback
|
||||
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)
|
||||
List<dynamic> _getAvailablePlayersSorted(MusicAssistantProvider maProvider) {
|
||||
// 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
|
||||
void _animateCommit(int direction, VoidCallback onSwitch) {
|
||||
if (_isSliding) return;
|
||||
_isSliding = true;
|
||||
|
||||
// Mark transition BEFORE animation - this keeps peek content visible
|
||||
// and hides main content to prevent any flash
|
||||
_inTransition = true;
|
||||
// Mark transition state in setState to ensure rebuilds happen properly
|
||||
setState(() {
|
||||
_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 targetOffset = direction < 0 ? -1.0 : 1.0;
|
||||
@@ -830,6 +919,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
final collapsedBg = themeProvider.adaptiveTheme && adaptiveScheme != null
|
||||
? adaptiveScheme.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);
|
||||
// Only update if we have adaptive colors, otherwise keep previous value
|
||||
if (adaptiveScheme != null) {
|
||||
@@ -837,7 +928,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
} else if (_currentExpandedBgColor == null) {
|
||||
_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
|
||||
? adaptiveScheme.onPrimaryContainer
|
||||
@@ -1008,9 +1101,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
child: GestureDetector(
|
||||
// Use translucent to allow child widgets (like buttons) to receive taps
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () {
|
||||
if (!isExpanded) expand();
|
||||
},
|
||||
// Only handle tap when collapsed - when expanded, let children handle their own taps
|
||||
onTap: isExpanded ? null : expand,
|
||||
onVerticalDragUpdate: (details) {
|
||||
if (details.primaryDelta! < -10 && !isExpanded) {
|
||||
expand();
|
||||
@@ -1070,6 +1162,46 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
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)
|
||||
// Show when: actively dragging (slideOffset != 0) OR in transition state
|
||||
// Must have peek data to render
|
||||
@@ -1093,45 +1225,53 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
|
||||
// Album art - with slide animation when collapsed
|
||||
// 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
|
||||
if (!(_inTransition && t < 0.1))
|
||||
if (!(_inTransition && t < 0.1 && _peekPlayer != null))
|
||||
Positioned(
|
||||
left: artLeft + miniPlayerSlideOffset,
|
||||
top: artTop,
|
||||
child: Container(
|
||||
width: artSize,
|
||||
height: artSize,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(artBorderRadius),
|
||||
// GPU PERF: Fixed blur/offset, only animate shadow opacity
|
||||
boxShadow: t > 0.3
|
||||
? [
|
||||
BoxShadow(
|
||||
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(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
// Use onTapUp instead of onTap - resolves immediately and wins
|
||||
// the gesture arena against the outer vertical drag detector
|
||||
onTapUp: t > 0.5 ? (_) => _showFullscreenArt(context, imageUrl) : null,
|
||||
child: Container(
|
||||
width: artSize,
|
||||
height: artSize,
|
||||
decoration: BoxDecoration(
|
||||
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),
|
||||
// GPU PERF: Fixed blur/offset, only animate shadow opacity
|
||||
boxShadow: t > 0.3
|
||||
? [
|
||||
BoxShadow(
|
||||
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),
|
||||
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
|
||||
// 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
|
||||
if (!(_inTransition && t < 0.1))
|
||||
if (!(_inTransition && t < 0.1 && _peekPlayer != null))
|
||||
Positioned(
|
||||
left: titleLeft + miniPlayerSlideOffset,
|
||||
top: titleTop,
|
||||
@@ -1164,8 +1305,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
|
||||
// Artist name - with slide animation when collapsed
|
||||
// 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
|
||||
if (!(_inTransition && t < 0.1))
|
||||
if (!(_inTransition && t < 0.1 && _peekPlayer != null))
|
||||
Positioned(
|
||||
left: titleLeft + miniPlayerSlideOffset,
|
||||
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) {
|
||||
return Container(
|
||||
color: Color.lerp(colorScheme.surfaceVariant, const Color(0xFF2a2a2a), t),
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: ensemble
|
||||
description: A minimalistic Music Assistant client built with Flutter
|
||||
publish_to: 'none'
|
||||
version: 1.9.2+13
|
||||
version: 1.9.3+14
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
Reference in New Issue
Block a user