Compare commits

...
10 Commits
Author SHA1 Message Date
Your Name 5109f8ce8c chore: bump version to 1.9.3+14 for release 2025-12-15 13:39:57 +00:00
Your NameandClaude Opus 4.5 71938034b6 Revert clip behavior and remove peek progress bar
- Reverted Stack clipBehavior back to Clip.none
- Removed peek progress bar (was causing app to freeze/go white)
- Main progress bar still slides with content

The peek progress bar and Clip.hardEdge were causing the app to become
unresponsive with a white overlay after swiping.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 13:23:59 +00:00
Your NameandClaude Opus 4.5 aaf2c7c91f Fix progress bar rendering during mini player swipe
- Refactored peek progress bar into helper method _buildPeekProgressBar
- Added null safety checks for peek player elapsed time
- Changed Stack clipBehavior to hardEdge to prevent overflow issues
- This should fix the white rendering issue during swipe

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 13:09:59 +00:00
Your NameandClaude Opus 4.5 15c634290e Make mini player progress bar slide with swipe gesture
- Progress bar now slides with album art, title, and artist during swipe
- Added peek player progress bar that slides in from opposite direction
- Peek progress shows the incoming player's playback position
- Non-playing peek players show no progress bar (as expected)
- Progress bar hidden during transition when peek content is available

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 12:55:39 +00:00
Your NameandClaude Opus 4.5 a4790ad054 Clarify Ensemble is an unofficial Music Assistant client
- Removed direct links to music-assistant.io
- Added clear disclaimer that Ensemble is unofficial and not affiliated
  with, endorsed by, or supported by the Music Assistant project
- Removed the "About Music Assistant" promotion section

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 12:46:46 +00:00
Your NameandClaude Opus 4.5 cff1c92ce2 Fix mini player assets not showing during swipe transitions
Bug: When swiping between players (especially between playing and
non-playing states), the album art, track title, and artist name
would disappear, leaving only the two-tone progress bar visible.

Root cause:
1. `_inTransition` flag was set without `setState`, causing rebuild
   timing issues with provider state changes
2. Main content visibility condition `!(_inTransition && t < 0.1)`
   would hide content during transitions, but if `_peekPlayer` was
   null or cleared prematurely, peek content wouldn't render either

Fix:
1. Wrap `_inTransition = true` in `setState` for proper rebuild timing
2. Add `_peekPlayer != null` to main content visibility condition as
   fallback - if peek content is unavailable during transition, main
   content will show instead of showing nothing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 12:31:35 +00:00
Your NameandClaude Opus 4.5 26356514ca fix: Remove provider icons and fix fullscreen art tap
- Remove provider icons feature from expanded player (caused overlay issues)
- Remove provider icons setting from settings screen
- Remove provider icon methods from settings service
- Fix fullscreen album art tap in expanded player by using onTapUp
  instead of onTap (resolves gesture arena conflict with vertical drag)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 07:40:29 +00:00
Your NameandClaude Opus 4.5 31801d261d fix: Fullscreen art tap handling and album view fullscreen
- Fix expanded player parent GestureDetector to not intercept taps when expanded
- Add fullscreen album art to album details screen
- Both support pinch-to-zoom and tap/swipe to dismiss

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 22:17:37 +00:00
Your NameandClaude Opus 4.5 ed4d876dee fix: UI fixes for mini player, fullscreen art, and provider icons
- Mini player progress bar now starts after album art (not hidden)
- Fullscreen album art tap detection fixed with opaque hit behavior
- Provider icons positioning simplified and error handling improved
- Album view padding increased to 164px for better clearance

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 21:52:29 +00:00
Your NameandClaude Opus 4.5 575b7520d6 feat: UI improvements for expanded and mini player
- Album view: Add 8px more bottom padding for mini player clearance
- Expanded player: Fix favourite button to properly handle provider mappings
- Expanded player: Add fullscreen album art on tap (pinch to zoom supported)
- Expanded player: Add optional provider icons on album art (Spotify, Subsonic, etc.)
- Settings: Add "Provider Icons" toggle under new Player section
- Mini player: Add two-tone progress bar background showing playback position

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 21:13:33 +00:00
4 changed files with 323 additions and 95 deletions
+3 -11
View File
@@ -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
+71 -27
View File
@@ -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
],
),
),
+248 -56
View File
@@ -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
View File
@@ -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'