Compare commits

...
Author SHA1 Message Date
ChrisandClaude Opus 4.5 c00fb3dc78 chore: Bump version to 2.9.1-beta
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 19:27:03 +00:00
ChrisandClaude Opus 4.5 ff34479169 feat: Provider icons and library performance optimizations
Provider Icons:
- Add provider icon overlays to series detail screen (grid/list)
- Add provider icon overlays to library playlists (grid/list)
- Add ProviderIconOverlay widget and provider manifest model

Performance Optimizations:
- Fix empty setState in SyncService listener - only rebuild on sync complete
- Add cached sorting for albums, artists, authors (avoid O(n log n) per rebuild)
- Add cached track sort keys for LetterScrollbar
- Fix image cache size mismatches (author 128px, audiobook list 128px, series 150px)
- Simplify provider toggle setState to avoid double rebuild
- Remove redundant text Hero animations from card widgets
- Defer color extraction from card tap to detail screen initState
- Increase fallback image debounce from 200ms to 400ms

Other Changes:
- Update screenshots
- Add new localizations
- Various detail screen improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 19:24:37 +00:00
41 changed files with 1346 additions and 648 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 649 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 502 KiB

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 478 KiB

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 909 KiB

Binary file not shown.
+25
View File
@@ -1292,6 +1292,16 @@
"description": "Description for smart sort players setting"
},
"disableAutoSwitch": "Disable Auto-Switch",
"@disableAutoSwitch": {
"description": "Setting to disable automatic player switching"
},
"disableAutoSwitchDescription": "Never automatically switch to a different player",
"@disableAutoSwitchDescription": {
"description": "Description for disable auto-switch setting"
},
"playerStateUnavailable": "Unavailable",
"@playerStateUnavailable": {
"description": "Player state when unavailable"
@@ -1347,6 +1357,21 @@
"description": "Description for show hints toggle"
},
"display": "Display",
"@display": {
"description": "Settings section title for display options"
},
"showProviderIcons": "Show Provider Icons",
"@showProviderIcons": {
"description": "Toggle label for showing provider icons on album art"
},
"showProviderIconsDescription": "Display provider logos on album art",
"@showProviderIconsDescription": {
"description": "Description for show provider icons toggle"
},
"pullToSelectPlayers": "Pull to select players",
"@pullToSelectPlayers": {
"description": "Hint shown when mini player bounces"
+30
View File
@@ -1761,6 +1761,18 @@ abstract class S {
/// **'Sort by status (playing, on, off) instead of alphabetically'**
String get smartSortPlayersDescription;
/// Setting to disable automatic player switching
///
/// In en, this message translates to:
/// **'Disable Auto-Switch'**
String get disableAutoSwitch;
/// Description for disable auto-switch setting
///
/// In en, this message translates to:
/// **'Never automatically switch to a different player'**
String get disableAutoSwitchDescription;
/// Player state when unavailable
///
/// In en, this message translates to:
@@ -1821,6 +1833,24 @@ abstract class S {
/// **'Display helpful tips for discovering features'**
String get showHintsDescription;
/// Settings section title for display options
///
/// In en, this message translates to:
/// **'Display'**
String get display;
/// Toggle label for showing provider icons on album art
///
/// In en, this message translates to:
/// **'Show Provider Icons'**
String get showProviderIcons;
/// Description for show provider icons toggle
///
/// In en, this message translates to:
/// **'Display provider logos on album art'**
String get showProviderIconsDescription;
/// Hint shown when mini player bounces
///
/// In en, this message translates to:
+15
View File
@@ -909,6 +909,12 @@ class SDe extends S {
@override
String get smartSortPlayersDescription => 'Nach Status sortieren (spielt, an, aus) statt alphabetisch';
@override
String get disableAutoSwitch => 'Disable Auto-Switch';
@override
String get disableAutoSwitchDescription => 'Never automatically switch to a different player';
@override
String get playerStateUnavailable => 'Nicht verfügbar';
@@ -941,6 +947,15 @@ class SDe extends S {
@override
String get showHintsDescription => 'Hilfreiche Tipps zum Entdecken von Funktionen anzeigen';
@override
String get display => 'Display';
@override
String get showProviderIcons => 'Show Provider Icons';
@override
String get showProviderIconsDescription => 'Display provider logos on album art';
@override
String get pullToSelectPlayers => 'Ziehen um Geräte auszuwählen';
+15
View File
@@ -909,6 +909,12 @@ class SEn extends S {
@override
String get smartSortPlayersDescription => 'Sort by status (playing, on, off) instead of alphabetically';
@override
String get disableAutoSwitch => 'Disable Auto-Switch';
@override
String get disableAutoSwitchDescription => 'Never automatically switch to a different player';
@override
String get playerStateUnavailable => 'Unavailable';
@@ -941,6 +947,15 @@ class SEn extends S {
@override
String get showHintsDescription => 'Display helpful tips for discovering features';
@override
String get display => 'Display';
@override
String get showProviderIcons => 'Show Provider Icons';
@override
String get showProviderIconsDescription => 'Display provider logos on album art';
@override
String get pullToSelectPlayers => 'Pull to select players';
+15
View File
@@ -909,6 +909,12 @@ class SEs extends S {
@override
String get smartSortPlayersDescription => 'Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente';
@override
String get disableAutoSwitch => 'Disable Auto-Switch';
@override
String get disableAutoSwitchDescription => 'Never automatically switch to a different player';
@override
String get playerStateUnavailable => 'No disponible';
@@ -941,6 +947,15 @@ class SEs extends S {
@override
String get showHintsDescription => 'Mostrar consejos útiles para descubrir funciones';
@override
String get display => 'Display';
@override
String get showProviderIcons => 'Show Provider Icons';
@override
String get showProviderIconsDescription => 'Display provider logos on album art';
@override
String get pullToSelectPlayers => 'Desliza para seleccionar dispositivos';
+15
View File
@@ -909,6 +909,12 @@ class SFr extends S {
@override
String get smartSortPlayersDescription => 'Trier par statut (en lecture, allume, eteint) au lieu d\'alphabétiquement';
@override
String get disableAutoSwitch => 'Disable Auto-Switch';
@override
String get disableAutoSwitchDescription => 'Never automatically switch to a different player';
@override
String get playerStateUnavailable => 'Indisponible';
@@ -941,6 +947,15 @@ class SFr extends S {
@override
String get showHintsDescription => 'Afficher des conseils utiles pour découvrir les fonctionnalites';
@override
String get display => 'Display';
@override
String get showProviderIcons => 'Show Provider Icons';
@override
String get showProviderIconsDescription => 'Display provider logos on album art';
@override
String get pullToSelectPlayers => 'Tirez pour sélectionner les lecteurs';
+65
View File
@@ -0,0 +1,65 @@
/// Represents a provider manifest from Music Assistant.
///
/// Contains metadata about a provider type including its icons.
/// This is separate from ProviderInstance which represents a configured
/// instance of a provider.
class ProviderManifest {
/// The provider domain/type (e.g., "spotify", "tidal", "qobuz")
final String domain;
/// Display name for this provider type
final String name;
/// Description of the provider
final String? description;
/// SVG icon as XML string
final String? iconSvg;
/// SVG icon for dark theme as XML string
final String? iconSvgDark;
/// Monochrome SVG icon as XML string
final String? iconSvgMonochrome;
/// Material Design icon name (fallback)
final String? icon;
ProviderManifest({
required this.domain,
required this.name,
this.description,
this.iconSvg,
this.iconSvgDark,
this.iconSvgMonochrome,
this.icon,
});
/// Whether this manifest has any SVG icon available
bool get hasSvgIcon =>
iconSvg != null || iconSvgDark != null || iconSvgMonochrome != null;
/// Get the best available SVG icon for the given brightness
/// Prefers dark icon for dark themes, falls back to regular or monochrome
String? getSvgIcon({bool isDark = false}) {
if (isDark && iconSvgDark != null) {
return iconSvgDark;
}
return iconSvg ?? iconSvgMonochrome;
}
factory ProviderManifest.fromJson(Map<String, dynamic> json) {
return ProviderManifest(
domain: json['domain'] as String? ?? '',
name: json['name'] as String? ?? '',
description: json['description'] as String?,
iconSvg: json['icon_svg'] as String?,
iconSvgDark: json['icon_svg_dark'] as String?,
iconSvgMonochrome: json['icon_svg_monochrome'] as String?,
icon: json['icon'] as String?,
);
}
@override
String toString() => 'ProviderManifest($domain: $name)';
}
+78 -54
View File
@@ -8,6 +8,7 @@ import 'package:flutter_volume_controller/flutter_volume_controller.dart';
import '../models/media_item.dart';
import '../models/player.dart';
import '../models/provider_instance.dart';
import '../models/provider_manifest.dart';
import '../services/music_assistant_api.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
@@ -63,6 +64,8 @@ class MusicAssistantProvider with ChangeNotifier {
Player? _selectedPlayer;
List<Player> _availablePlayers = [];
bool _selectPlayerInProgress = false; // Reentrancy guard for selectPlayer()
DateTime? _lastPlayerSwitchTime; // Debounce for auto-selection
static const _playerSwitchCooldown = Duration(seconds: 3); // Minimum time between auto-switches
Map<String, String> _castToSendspinIdMap = {}; // Maps regular Cast IDs to Sendspin IDs for grouping
Track? _currentTrack;
Audiobook? _currentAudiobook; // Currently playing audiobook context (with chapters)
@@ -227,6 +230,21 @@ class MusicAssistantProvider with ChangeNotifier {
/// List of available music providers from MA
List<ProviderInstance> get availableMusicProviders => _availableMusicProviders;
/// Get all provider manifests (contains icons)
Map<String, ProviderManifest> get providerManifests =>
_api?.providerManifests ?? {};
/// Get SVG icon for a provider domain
/// Returns null if no icon is available
String? getProviderIconSvg(String domain, {bool isDark = false}) {
return _api?.getProviderIconSvg(domain, isDark: isDark);
}
/// Get provider manifest by domain
ProviderManifest? getProviderManifest(String domain) {
return _api?.getProviderManifest(domain);
}
/// List of enabled provider instance IDs (empty = all enabled)
List<String> get enabledProviderIds => _enabledProviderIds;
@@ -3727,8 +3745,13 @@ class MusicAssistantProvider with ChangeNotifier {
if (_availablePlayers.isNotEmpty) {
Player? playerToSelect;
final preferLocalPlayer = await SettingsService.getPreferLocalPlayer();
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
final lastSelectedPlayerId = await SettingsService.getLastSelectedPlayerId();
// Log settings for debugging player selection issues
_logger.log('⚙️ Player selection settings: preferLocal=$preferLocalPlayer, disableAutoSwitch=$disableAutoSwitch, coldStart=$coldStart');
_logger.log('⚙️ Current selection: ${_selectedPlayer?.name ?? 'none'}, lastSelected=$lastSelectedPlayerId');
// If "Prefer Local Player" is ON, always try to select local player first
// This takes priority even over the currently selected player
if (preferLocalPlayer && builtinPlayerId != null) {
@@ -3739,12 +3762,12 @@ class MusicAssistantProvider with ChangeNotifier {
_logger.log('📱 Auto-selected local player (preferred): ${playerToSelect?.name}');
} catch (e) {
// Local player not available yet, will fall through to other options
_logger.log('⚠️ Prefer local player ON but local player not available');
}
}
// Keep currently selected player if still available (and not overridden by prefer local)
// But allow switching to a playing player when preferLocalPlayer is OFF
// On coldStart, skip this block and apply full priority logic (playing > local > last selected)
// On coldStart, skip this block and apply full priority logic
if (playerToSelect == null && _selectedPlayer != null && !coldStart) {
// Check if selected player is still available - also check translated Cast/Sendspin IDs
// When Cast player gets replaced by Sendspin version (or vice versa), we should keep selection
@@ -3771,71 +3794,73 @@ class MusicAssistantProvider with ChangeNotifier {
(reverseTranslatedId != null && p.playerId == reverseTranslatedId),
);
// If preferLocalPlayer is OFF, check if we should switch to a playing player
if (!preferLocalPlayer) {
final currentIsPlaying = currentPlayer.state == 'playing';
// Exclude external sources - they're not playing MA content
final playingPlayers = _availablePlayers.where(
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
).toList();
// Switch to playing player only if current isn't playing and exactly one other is
if (!currentIsPlaying && playingPlayers.length == 1) {
playerToSelect = playingPlayers.first;
_logger.log('🎵 Switched to playing player: ${playerToSelect?.name}');
}
}
// Keep current selection if no switch happened
if (playerToSelect == null) {
playerToSelect = currentPlayer;
if (currentPlayer.playerId != selectedId) {
_logger.log('🔄 Selected player ID changed (Cast<->Sendspin): $selectedId -> ${currentPlayer.playerId}');
}
// Keep current selection - no auto-switching during non-coldStart refreshes
// Auto-switch to playing player is now ONLY done on coldStart (see below)
playerToSelect = currentPlayer;
if (currentPlayer.playerId != selectedId) {
_logger.log('🔄 Selected player ID changed (Cast<->Sendspin): $selectedId -> ${currentPlayer.playerId}');
} else {
_logger.log('✓ Keeping current selection: ${currentPlayer.name}');
}
}
}
if (coldStart) {
_logger.log('🚀 Cold start: applying full priority logic (playing > local > last selected)');
_logger.log('🚀 Cold start: applying full priority logic');
}
if (playerToSelect == null) {
// Smart auto-selection priority:
// If "Prefer Local Player" is OFF: Single playing player -> Local player -> Last selected -> First available
// Smart auto-selection priority (coldStart only for auto-switch to playing):
// Priority 1: Single playing player (only on coldStart and if disableAutoSwitch is OFF)
// Priority 2: Local player (if preferLocalPlayer is OFF but we still try local as fallback)
// Priority 3: Last manually selected player
// Priority 4: First available player
if (!preferLocalPlayer) {
// Priority 1 (normal): Single playing player (skip if multiple are playing)
// Exclude external sources - they're not playing MA content
// Priority 1: Auto-switch to single playing player
// ONLY on coldStart AND only if disableAutoSwitch is OFF
// Also apply cooldown to prevent rapid successive auto-switches
final cooldownActive = _lastPlayerSwitchTime != null &&
DateTime.now().difference(_lastPlayerSwitchTime!) < _playerSwitchCooldown;
if (coldStart && !disableAutoSwitch && !preferLocalPlayer && !cooldownActive) {
final playingPlayers = _availablePlayers.where(
(p) => p.state == 'playing' && p.available && !p.isExternalSource,
).toList();
if (playingPlayers.length == 1) {
playerToSelect = playingPlayers.first;
_lastPlayerSwitchTime = DateTime.now(); // Update cooldown timestamp
_logger.log('🎵 Auto-selected playing player: ${playerToSelect?.name}');
} else if (playingPlayers.length > 1) {
_logger.log('⚠️ Multiple players playing (${playingPlayers.length}), skipping auto-switch');
}
} else if (cooldownActive) {
_logger.log('⏳ Auto-switch cooldown active, skipping');
} else if (!coldStart) {
_logger.log('⚠️ Not coldStart, skipping auto-switch to playing player');
} else if (disableAutoSwitch) {
_logger.log('⚠️ Auto-switch disabled by user setting');
}
// Priority 2: Local player
if (playerToSelect == null && builtinPlayerId != null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == builtinPlayerId && p.available,
);
_logger.log('📱 Auto-selected local player: ${playerToSelect?.name}');
} catch (e) {
// Local player not found or not available - check why
final localPlayer = _availablePlayers
.where((p) => p.playerId == builtinPlayerId)
.toList();
if (localPlayer.isEmpty) {
_logger.log('⚠️ Priority 2 skipped: local player ($builtinPlayerId) not in available players list');
} else {
_logger.log('⚠️ Priority 2 skipped: local player found but available=${localPlayer.first.available}');
}
// Priority 2: Local player (fallback even when preferLocalPlayer is OFF)
if (playerToSelect == null && builtinPlayerId != null) {
try {
playerToSelect = _availablePlayers.firstWhere(
(p) => p.playerId == builtinPlayerId && p.available,
);
_logger.log('📱 Auto-selected local player: ${playerToSelect?.name}');
} catch (e) {
// Local player not found or not available - check why
final localPlayer = _availablePlayers
.where((p) => p.playerId == builtinPlayerId)
.toList();
if (localPlayer.isEmpty) {
_logger.log('⚠️ Priority 2 skipped: local player ($builtinPlayerId) not in available players list');
} else {
_logger.log('⚠️ Priority 2 skipped: local player found but available=${localPlayer.first.available}');
}
} else if (playerToSelect == null && builtinPlayerId == null) {
_logger.log('⚠️ Priority 2 skipped: builtinPlayerId is null');
}
} else if (playerToSelect == null && builtinPlayerId == null) {
_logger.log('⚠️ Priority 2 skipped: builtinPlayerId is null');
}
// Priority 3: Last manually selected player
@@ -3855,6 +3880,7 @@ class MusicAssistantProvider with ChangeNotifier {
(p) => p.available,
orElse: () => _availablePlayers.first,
);
_logger.log('🔄 Fallback to first available player: ${playerToSelect.name}');
}
}
@@ -3890,12 +3916,10 @@ class MusicAssistantProvider with ChangeNotifier {
// IMPORTANT: Always set from cache, even if null - this prevents showing
// stale track info when switching to a non-playing player.
// For grouped child players, this returns the leader's track
// IMPORTANT: Clear track for external sources - they're playing non-MA content
if (player.isExternalSource) {
_currentTrack = null;
} else {
_currentTrack = getCachedTrackForPlayer(player.playerId);
}
// NOTE: Always use cache even for external sources - the overlay shows cached
// track info, so we should match that. The async _updatePlayerState() will
// clear it if the player is truly playing external content.
_currentTrack = getCachedTrackForPlayer(player.playerId);
// Initialize position tracker for this player (skip for external sources)
_positionTracker.onPlayerSelected(player.playerId);
+37 -22
View File
@@ -10,6 +10,7 @@ import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
import '../services/recently_played_service.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
import 'artist_details_screen.dart';
@@ -877,31 +878,45 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
// FIXED: Match source structure - ClipRRect(12) → Container → CachedNetworkImage
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
child: SizedBox(
width: coverSize,
height: coverSize,
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.album_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.album_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.album_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.album_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
// Provider icon overlay
if (widget.album.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.album.providerMappings!.first.providerDomain,
size: 24,
margin: 8,
),
],
),
),
),
),
+41 -29
View File
@@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart';
import 'album_details_screen.dart';
import '../constants/hero_tags.dart';
import '../theme/palette_helper.dart';
@@ -366,6 +367,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
_isInLibrary = !newState;
});
}
return false;
});
} else {
// Remove from library
@@ -413,6 +415,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
_isInLibrary = !newState;
});
}
return false;
});
}
} catch (e) {
@@ -1002,37 +1005,46 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
children: [
AspectRatio(
aspectRatio: 1.0, // Square album art
child: Hero(
tag: HeroTags.albumCover + (album.uri ?? album.itemId) + '_$heroSuffix',
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) => Center(
child: Icon(
Icons.album_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
child: Stack(
children: [
Hero(
tag: HeroTags.albumCover + (album.uri ?? album.itemId) + '_$heroSuffix',
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) => Center(
child: Icon(
Icons.album_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
Icons.album_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
)
: Center(
child: Icon(
Icons.album_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
),
// Provider icon overlay
if (album.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: album.providerMappings!.first.providerDomain,
),
],
),
),
Spacing.vGap8,
+6 -3
View File
@@ -5,15 +5,14 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../services/settings_service.dart';
import '../services/metadata_service.dart';
import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
import '../constants/hero_tags.dart';
import '../l10n/app_localizations.dart';
import 'audiobook_detail_screen.dart';
import '../widgets/provider_icon.dart';
class AudiobookAuthorScreen extends StatefulWidget {
final String authorName;
@@ -34,7 +33,6 @@ class AudiobookAuthorScreen extends StatefulWidget {
}
class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
final _logger = DebugLogger();
late List<Audiobook> _audiobooks;
ColorScheme? _lightColorScheme;
ColorScheme? _darkColorScheme;
@@ -407,6 +405,11 @@ class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
minHeight: 3,
),
),
// Provider icon overlay
if (book.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: book.providerMappings!.first.providerDomain,
),
],
),
),
+47 -32
View File
@@ -5,6 +5,7 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../services/debug_logger.dart';
@@ -497,41 +498,55 @@ class _AudiobookDetailScreenState extends State<AudiobookDetailScreen> {
tag: HeroTags.audiobookCover + (widget.audiobook.uri ?? widget.audiobook.itemId) + _heroTagSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
child: SizedBox(
width: coverSize,
height: coverSize,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero animation
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.bookOutline,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.bookOutline,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.bookOutline,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero animation
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.bookOutline,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.bookOutline,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.bookOutline,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
),
// Provider icon overlay
if (widget.audiobook.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.audiobook.providerMappings!.first.providerDomain,
size: 24,
margin: 8,
),
],
),
),
),
),
+51 -35
View File
@@ -6,6 +6,7 @@ import 'package:palette_generator/palette_generator.dart';
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart' show BottomSpacing;
import '../widgets/provider_icon.dart';
import '../services/settings_service.dart';
import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
@@ -690,6 +691,11 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
),
),
),
// Provider icon overlay
if (book.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: book.providerMappings!.first.providerDomain,
),
// Progress indicator
if (book.progress > 0)
Positioned(
@@ -748,45 +754,55 @@ class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Stack(
children: [
Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
child: SizedBox(
width: 56,
height: 56,
child: Stack(
children: [
Container(
width: 56,
height: 56,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
errorWidget: (_, __, ___) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
errorWidget: (_, __, ___) => Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
MdiIcons.bookOutline,
color: colorScheme.onSurfaceVariant,
),
),
if (book.progress > 0)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: LinearProgressIndicator(
value: book.progress,
backgroundColor: Colors.black54,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
minHeight: 2,
),
),
],
// Provider icon overlay
if (book.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: book.providerMappings!.first.providerDomain,
size: 16,
),
if (book.progress > 0)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: LinearProgressIndicator(
value: book.progress,
backgroundColor: Colors.black54,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
minHeight: 2,
),
),
],
),
),
),
),
+292 -168
View File
@@ -11,6 +11,7 @@ import '../models/provider_instance.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/album_card.dart';
import '../widgets/artist_avatar.dart';
import '../widgets/provider_icon.dart';
import '../utils/page_transitions.dart';
import '../constants/hero_tags.dart';
import '../theme/theme_provider.dart';
@@ -57,6 +58,7 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
bool _showOnlyArtistsWithAlbums = false; // Filter artists tab to only show those with albums
bool _isSyncingArtists = false; // Show progress while re-syncing artists
bool _isSyncingLibraries = false; // Show progress while re-syncing ABS libraries
bool _isSyncingProviders = false; // Show progress while syncing after provider toggle
// All tracks with lazy loading
List<Track> _allTracks = [];
@@ -79,6 +81,17 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
List<AudiobookSeries> _sortedSeries = [];
List<String> _seriesNames = [];
// PERF: Cached sorted albums/artists - only re-sort when data or sort order changes
List<Album> _cachedSortedAlbums = [];
List<String> _cachedAlbumNames = [];
String _albumsCacheKey = '';
List<Artist> _cachedSortedArtists = [];
List<String> _cachedArtistNames = [];
String _artistsCacheKey = '';
String _authorsCacheKey = '';
List<String> _cachedTrackSortKeys = [];
String _trackSortKeysCacheKey = '';
// Media type selection (Music, Books, Podcasts)
LibraryMediaType _selectedMediaType = LibraryMediaType.music;
@@ -221,10 +234,10 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
}
void _onSyncServiceChanged() {
// Rebuild when SyncService data changes (e.g., after sync completes)
if (mounted) {
setState(() {});
}
// Only rebuild when sync completes, not during sync
// SyncService.isSyncing is already checked in menu button spinner
if (!mounted || SyncService.instance.isSyncing) return;
setState(() {});
}
void _onNavigationChanged() {
@@ -283,13 +296,14 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
void _handleProviderToggle(String providerId, bool enabled) {
final maProvider = context.read<MusicAssistantProvider>();
// PERF: Set flag without setState - Provider change already triggers rebuild
// This avoids a redundant double rebuild
_isSyncingProviders = true;
// Instant UI update - client-side filtering will use the new enabled set
// The toggle updates MusicAssistantProvider.enabledProviderIds which we read during build
maProvider.toggleProviderEnabled(providerId, enabled);
// Immediately rebuild the UI with client-side filtering
setState(() {});
// Cancel any pending debounce for background sync
_providerFilterDebounce?.cancel();
@@ -300,7 +314,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
DebugLogger().log('🔄 Background sync to refresh source tracking...');
await maProvider.forceLibrarySync();
if (mounted) {
setState(() {});
setState(() {
_isSyncingProviders = false;
});
}
}
});
@@ -429,6 +445,139 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
_audiobookNames = sorted.map((a) => a.name).toList();
}
/// Sort albums and cache the result - only re-sorts when key changes
/// Returns cached sorted list if data hasn't changed
List<Album> _getSortedAlbums(List<Album> albums) {
final cacheKey = '${albums.length}_${albums.isNotEmpty ? albums.first.itemId : ''}_${albums.isNotEmpty ? albums.last.itemId : ''}_$_albumsSortOrder';
if (cacheKey == _albumsCacheKey && _cachedSortedAlbums.isNotEmpty) {
return _cachedSortedAlbums;
}
final sortedAlbums = List<Album>.from(albums);
switch (_albumsSortOrder) {
case 'name_desc':
sortedAlbums.sort((a, b) => (b.sortName ?? b.name ?? '').toLowerCase().compareTo((a.sortName ?? a.name ?? '').toLowerCase()));
break;
case 'year':
sortedAlbums.sort((a, b) {
if (a.year == null && b.year == null) return (a.name ?? '').compareTo(b.name ?? '');
if (a.year == null) return 1;
if (b.year == null) return -1;
return a.year!.compareTo(b.year!);
});
break;
case 'year_desc':
sortedAlbums.sort((a, b) {
if (a.year == null && b.year == null) return (a.name ?? '').compareTo(b.name ?? '');
if (a.year == null) return 1;
if (b.year == null) return -1;
return b.year!.compareTo(a.year!);
});
break;
case 'artist_name':
sortedAlbums.sort((a, b) => (a.artistsString).toLowerCase().compareTo((b.artistsString).toLowerCase()));
break;
case 'artist_name_desc':
sortedAlbums.sort((a, b) => (b.artistsString).toLowerCase().compareTo((a.artistsString).toLowerCase()));
break;
default:
// Default: name ascending
sortedAlbums.sort((a, b) => (a.sortName ?? a.name ?? '').toLowerCase().compareTo((b.sortName ?? b.name ?? '').toLowerCase()));
}
_albumsCacheKey = cacheKey;
_cachedSortedAlbums = sortedAlbums;
_cachedAlbumNames = sortedAlbums.map((a) => a.name ?? '').toList();
return sortedAlbums;
}
/// Sort artists and cache the result - only re-sorts when key changes
List<Artist> _getSortedArtists(List<Artist> artists) {
final cacheKey = '${artists.length}_${artists.isNotEmpty ? artists.first.itemId : ''}_${artists.isNotEmpty ? artists.last.itemId : ''}_$_artistsSortOrder';
if (cacheKey == _artistsCacheKey && _cachedSortedArtists.isNotEmpty) {
return _cachedSortedArtists;
}
final sortedArtists = List<Artist>.from(artists);
if (_artistsSortOrder == 'name_desc') {
sortedArtists.sort((a, b) => (b.sortName ?? b.name ?? '').toLowerCase().compareTo((a.sortName ?? a.name ?? '').toLowerCase()));
} else {
// Default: name ascending
sortedArtists.sort((a, b) => (a.sortName ?? a.name ?? '').toLowerCase().compareTo((b.sortName ?? b.name ?? '').toLowerCase()));
}
_artistsCacheKey = cacheKey;
_cachedSortedArtists = sortedArtists;
_cachedArtistNames = sortedArtists.map((a) => a.name ?? '').toList();
return sortedArtists;
}
/// Sort authors and cache the result - groups audiobooks by author and sorts names
(List<String>, Map<String, List<Audiobook>>) _getSortedAuthors(List<Audiobook> audiobooks) {
final cacheKey = '${audiobooks.length}_${audiobooks.isNotEmpty ? audiobooks.first.itemId : ''}_${audiobooks.isNotEmpty ? audiobooks.last.itemId : ''}_$_authorsSortOrder';
if (cacheKey == _authorsCacheKey && _sortedAuthorNames.isNotEmpty) {
return (_sortedAuthorNames, _groupedAudiobooksByAuthor);
}
// Group audiobooks by author
final groupedByAuthor = <String, List<Audiobook>>{};
for (final book in audiobooks) {
final authorName = book.authorsString;
groupedByAuthor.putIfAbsent(authorName, () => []).add(book);
}
// Sort authors based on current sort order
final sortedNames = groupedByAuthor.keys.toList();
switch (_authorsSortOrder) {
case 'alpha':
sortedNames.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
break;
case 'alpha_desc':
sortedNames.sort((a, b) => b.toLowerCase().compareTo(a.toLowerCase()));
break;
case 'books':
sortedNames.sort((a, b) {
final aCount = groupedByAuthor[a]?.length ?? 0;
final bCount = groupedByAuthor[b]?.length ?? 0;
if (bCount != aCount) return bCount.compareTo(aCount);
return a.toLowerCase().compareTo(b.toLowerCase());
});
break;
}
_authorsCacheKey = cacheKey;
_sortedAuthorNames = sortedNames;
_groupedAudiobooksByAuthor = groupedByAuthor;
return (sortedNames, groupedByAuthor);
}
/// Get track sort keys for LetterScrollbar - cached to avoid recomputation on every build
List<String> _getTrackSortKeys(List<Track> tracks) {
final cacheKey = '${tracks.length}_${tracks.isNotEmpty ? tracks.first.itemId : ''}_${tracks.isNotEmpty ? tracks.last.itemId : ''}_$_tracksSortOrder';
if (cacheKey == _trackSortKeysCacheKey && _cachedTrackSortKeys.isNotEmpty) {
return _cachedTrackSortKeys;
}
final List<String> sortKeys;
switch (_tracksSortOrder) {
case 'artist':
case 'artist_name':
sortKeys = tracks.map((t) => t.artistsString).toList();
break;
case 'album':
sortKeys = tracks.map((t) => t.album?.name ?? t.name).toList();
break;
default:
// For name, duration, timestamp, etc. - use track name for letter scrollbar
sortKeys = tracks.map((t) => t.name).toList();
break;
}
_trackSortKeysCacheKey = cacheKey;
_cachedTrackSortKeys = sortKeys;
return sortKeys;
}
String _getCurrentViewMode() {
// Return the view mode for the currently selected tab
final tabIndex = _selectedTabIndex.value;
@@ -1660,8 +1809,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => _showOptionsMenu(colorScheme),
// Show spinner on button while syncing artists or libraries filter
child: (_isSyncingArtists || _isSyncingLibraries)
// Show spinner on button while syncing (artists filter, libraries filter, provider filter, or initial library build)
child: (_isSyncingArtists || _isSyncingLibraries || _isSyncingProviders || SyncService.instance.isSyncing)
? Center(
child: SizedBox.square(
dimension: 18,
@@ -1770,7 +1919,11 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
if (mounted) {
await context.read<MusicAssistantProvider>().forceLibrarySync();
if (mounted) {
setState(() => _isSyncingLibraries = false);
// Reload audiobooks to reflect the new filter
await _loadAudiobooks(favoriteOnly: _showFavoritesOnly ? true : null);
if (mounted) {
setState(() => _isSyncingLibraries = false);
}
}
}
}
@@ -2130,31 +2283,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
);
}
// Group filtered audiobooks by author
final groupedByAuthor = <String, List<Audiobook>>{};
for (final book in audiobooks) {
final authorName = book.authorsString;
groupedByAuthor.putIfAbsent(authorName, () => []).add(book);
}
// Sort authors based on current sort order
final sortedAuthorNames = groupedByAuthor.keys.toList();
switch (_authorsSortOrder) {
case 'alpha':
sortedAuthorNames.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
break;
case 'alpha_desc':
sortedAuthorNames.sort((a, b) => b.toLowerCase().compareTo(a.toLowerCase()));
break;
case 'books':
sortedAuthorNames.sort((a, b) {
final aCount = groupedByAuthor[a]?.length ?? 0;
final bCount = groupedByAuthor[b]?.length ?? 0;
if (bCount != aCount) return bCount.compareTo(aCount);
return a.toLowerCase().compareTo(b.toLowerCase());
});
break;
}
// PERF: Use cached grouping/sorting - only re-sorts when data or sort order changes
final (sortedAuthorNames, groupedByAuthor) = _getSortedAuthors(audiobooks);
// Match music artists tab layout - no header, direct list/grid
return RefreshIndicator(
@@ -2239,8 +2369,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
fit: BoxFit.cover,
width: 48,
height: 48,
// Only set width - height scales proportionally to preserve aspect ratio
// PERF: Match display size to avoid unnecessary memory usage
memCacheWidth: 128,
memCacheHeight: 128,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
// Transparent placeholder - icon shows through
@@ -2589,8 +2720,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 512,
memCacheHeight: 512,
// PERF: Reduced from 512 to 128 - display is ~56px
memCacheWidth: 128,
memCacheHeight: 128,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
@@ -2628,6 +2760,11 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
),
),
),
// Provider icon overlay
if (book.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: book.providerMappings!.first.providerDomain,
),
],
),
),
@@ -3022,6 +3159,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: CachedNetworkImage(
imageUrl: displayCovers[index],
fit: BoxFit.cover,
// PERF: Add cache sizing for series cover thumbnails
memCacheWidth: 150,
memCacheHeight: 150,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Container(
@@ -3303,33 +3443,43 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
// Match detail screen structure exactly
child: Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// FIXED: Add memCacheWidth to ensure consistent decode size for smooth Hero
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.podcast,
size: 48,
color: colorScheme.onSurfaceVariant,
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// FIXED: Add memCacheWidth to ensure consistent decode size for smooth Hero
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.podcast,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.podcast,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
),
)
: Center(
child: Icon(
MdiIcons.podcast,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
),
// Provider icon overlay
if (podcast.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: podcast.providerMappings!.first.providerDomain,
),
],
),
),
),
@@ -3650,15 +3800,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
);
}
// Apply client-side sorting since we're combining data from multiple providers
final sortedArtists = List<Artist>.from(artists);
if (_artistsSortOrder == 'name_desc') {
sortedArtists.sort((a, b) => (b.sortName ?? b.name ?? '').toLowerCase().compareTo((a.sortName ?? a.name ?? '').toLowerCase()));
} else {
// Default: name ascending
sortedArtists.sort((a, b) => (a.sortName ?? a.name ?? '').toLowerCase().compareTo((b.sortName ?? b.name ?? '').toLowerCase()));
}
final artistNames = sortedArtists.map((a) => a.name ?? '').toList();
// PERF: Use cached sorting - only re-sorts when data or sort order changes
final sortedArtists = _getSortedArtists(artists);
final artistNames = _cachedArtistNames;
return RefreshIndicator(
color: colorScheme.primary,
@@ -3866,39 +4010,9 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
);
}
// Apply client-side sorting since we're combining data from multiple providers
final sortedAlbums = List<Album>.from(albums);
switch (_albumsSortOrder) {
case 'name_desc':
sortedAlbums.sort((a, b) => (b.sortName ?? b.name ?? '').toLowerCase().compareTo((a.sortName ?? a.name ?? '').toLowerCase()));
break;
case 'year':
sortedAlbums.sort((a, b) {
if (a.year == null && b.year == null) return (a.name ?? '').compareTo(b.name ?? '');
if (a.year == null) return 1;
if (b.year == null) return -1;
return a.year!.compareTo(b.year!);
});
break;
case 'year_desc':
sortedAlbums.sort((a, b) {
if (a.year == null && b.year == null) return (a.name ?? '').compareTo(b.name ?? '');
if (a.year == null) return 1;
if (b.year == null) return -1;
return b.year!.compareTo(a.year!);
});
break;
case 'artist_name':
sortedAlbums.sort((a, b) => (a.artistsString).toLowerCase().compareTo((b.artistsString).toLowerCase()));
break;
case 'artist_name_desc':
sortedAlbums.sort((a, b) => (b.artistsString).toLowerCase().compareTo((a.artistsString).toLowerCase()));
break;
default:
// Default: name ascending
sortedAlbums.sort((a, b) => (a.sortName ?? a.name ?? '').toLowerCase().compareTo((b.sortName ?? b.name ?? '').toLowerCase()));
}
final albumNames = sortedAlbums.map((a) => a.name ?? '').toList();
// PERF: Use cached sorting - only re-sorts when data or sort order changes
final sortedAlbums = _getSortedAlbums(albums);
final albumNames = _cachedAlbumNames;
return RefreshIndicator(
color: colorScheme.primary,
@@ -4102,28 +4216,41 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + heroSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
child: SizedBox(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 96,
memCacheHeight: 96,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.playlist_play_rounded,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.playlist_play_rounded,
color: colorScheme.onSurfaceVariant,
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 96,
memCacheHeight: 96,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.playlist_play_rounded,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.playlist_play_rounded,
color: colorScheme.onSurfaceVariant,
),
),
// Provider icon overlay
if (playlist.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: playlist.providerMappings!.first.providerDomain,
size: 16,
),
],
),
),
),
),
@@ -4202,34 +4329,44 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + heroSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Center(
child: Icon(
Icons.playlist_play_rounded,
size: 48,
color: colorScheme.onSurfaceVariant,
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Center(
child: Icon(
Icons.playlist_play_rounded,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
Icons.playlist_play_rounded,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
),
)
: Center(
child: Icon(
Icons.playlist_play_rounded,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
),
// Provider icon overlay
if (playlist.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: playlist.providerMappings!.first.providerDomain,
),
],
),
),
),
@@ -4302,21 +4439,8 @@ class _NewLibraryScreenState extends State<NewLibraryScreen>
);
}
// Build items list based on current sort order for accurate letter display
final List<String> trackSortKeys;
switch (_tracksSortOrder) {
case 'artist':
case 'artist_name':
trackSortKeys = displayTracks.map((t) => t.artistsString).toList();
break;
case 'album':
trackSortKeys = displayTracks.map((t) => t.album?.name ?? t.name).toList();
break;
default:
// For name, duration, timestamp, etc. - use track name for letter scrollbar
trackSortKeys = displayTracks.map((t) => t.name).toList();
break;
}
// PERF: Use cached track sort keys - only recompute when data or sort order changes
final trackSortKeys = _getTrackSortKeys(displayTracks);
return RefreshIndicator(
color: colorScheme.primary,
+36 -21
View File
@@ -9,6 +9,7 @@ import '../theme/theme_provider.dart';
import '../services/debug_logger.dart';
import '../services/recently_played_service.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart';
import '../l10n/app_localizations.dart';
import '../theme/design_tokens.dart';
@@ -691,30 +692,44 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> with Sing
tag: HeroTags.playlistCover + (widget.playlist.uri ?? widget.playlist.itemId) + _heroTagSuffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
child: SizedBox(
width: coverSize,
height: coverSize,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.playlist_play_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.playlist_play_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => const SizedBox(),
errorWidget: (_, __, ___) => Icon(
Icons.playlist_play_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
)
: Icon(
Icons.playlist_play_rounded,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
// Provider icon overlay
if (widget.playlist.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.playlist.providerMappings!.first.providerDomain,
size: 24,
margin: 8,
),
],
),
),
),
),
+47 -32
View File
@@ -5,6 +5,7 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
import '../models/media_item.dart';
import '../providers/music_assistant_provider.dart';
import '../widgets/global_player_overlay.dart';
import '../widgets/provider_icon.dart';
import '../theme/palette_helper.dart';
import '../theme/theme_provider.dart';
import '../services/debug_logger.dart';
@@ -478,41 +479,55 @@ class _PodcastDetailScreenState extends State<PodcastDetailScreen> {
// Match audiobook pattern: ClipRRect + CachedNetworkImage for smooth hero
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Container(
child: SizedBox(
width: coverSize,
height: coverSize,
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero animation
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
// Match source memCacheWidth for smooth Hero animation
memCacheWidth: 256,
memCacheHeight: 256,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (_, __) => Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
errorWidget: (_, __, ___) => Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.podcast,
size: coverSize * 0.43,
color: colorScheme.onSurfaceVariant,
),
),
),
// Provider icon overlay
if (widget.podcast.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.podcast.providerMappings!.first.providerDomain,
size: 24,
margin: 8,
),
],
),
),
),
),
+73
View File
@@ -41,9 +41,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Player settings
bool _preferLocalPlayer = false;
bool _smartSortPlayers = false;
bool _disableAutoSwitch = false;
bool _volumePrecisionMode = true;
// Hint settings
bool _showHints = true;
// Display settings
bool _showProviderIcons = true;
@override
void initState() {
@@ -84,11 +87,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Load player settings
final preferLocal = await SettingsService.getPreferLocalPlayer();
final smartSort = await SettingsService.getSmartSortPlayers();
final disableAutoSwitch = await SettingsService.getDisableAutoSwitch();
final volumePrecision = await SettingsService.getVolumePrecisionMode();
// Load hint settings
final showHints = await SettingsService.getShowHints();
// Load display settings
final showProviderIcons = await SettingsService.getShowProviderIcons();
if (mounted) {
setState(() {
_showRecentAlbums = showRecent;
@@ -106,8 +113,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
_homeRowOrder = rowOrder;
_preferLocalPlayer = preferLocal;
_smartSortPlayers = smartSort;
_disableAutoSwitch = disableAutoSwitch;
_volumePrecisionMode = volumePrecision;
_showHints = showHints;
_showProviderIcons = showProviderIcons;
});
}
}
@@ -746,6 +755,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
S.of(context)!.disableAutoSwitch,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.disableAutoSwitchDescription,
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
),
value: _disableAutoSwitch,
onChanged: (value) {
setState(() => _disableAutoSwitch = value);
SettingsService.setDisableAutoSwitch(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
@@ -811,6 +845,45 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
const SizedBox(height: 24),
// Display section
Text(
S.of(context)!.display,
style: textTheme.titleMedium?.copyWith(
color: colorScheme.onBackground,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile(
title: Text(
S.of(context)!.showProviderIcons,
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
S.of(context)!.showProviderIconsDescription,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.6),
),
),
value: _showProviderIcons,
onChanged: (value) {
setState(() => _showProviderIcons = value);
SettingsService.setShowProviderIcons(value);
},
activeColor: colorScheme.primary,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 32),
// Home Screen section
+35 -5
View File
@@ -9,6 +9,7 @@ import '../constants/timings.dart' show Timings, LibraryConstants;
import '../models/media_item.dart';
import '../models/player.dart';
import '../models/provider_instance.dart';
import '../models/provider_manifest.dart';
import 'debug_logger.dart';
import 'settings_service.dart';
import 'device_id_service.dart';
@@ -60,6 +61,10 @@ class MusicAssistantAPI {
int? get schemaVersion => _schemaVersion;
Map<String, dynamic>? get serverInfo => _serverInfo;
// Provider manifests (contains icons)
final Map<String, ProviderManifest> _providerManifests = {};
Map<String, ProviderManifest> get providerManifests => _providerManifests;
MusicAssistantAPI(this.serverUrl, this.authManager);
// Guard to prevent multiple simultaneous connection attempts
@@ -821,10 +826,11 @@ class MusicAssistantAPI {
// Only call library_items API if:
// 1. No ABS libraries configured (traditional mode), OR
// 2. We have non-ABS providers to query
// 2. We have specific non-ABS providers to query
// NOTE: Do NOT call when ABS filtering is active but no non-ABS providers exist,
// as library_items API would return all audiobooks bypassing the library filter
final shouldFetchFromApi = enabledLibraries == null ||
enabledLibraries.isEmpty ||
(nonAbsProviders == null && absProviderIds.isNotEmpty) ||
(nonAbsProviders != null && nonAbsProviders.isNotEmpty);
if (shouldFetchFromApi) {
@@ -3422,10 +3428,22 @@ class MusicAssistantAPI {
try {
_logger.log('📥 Fetching initial state...');
// Fetch provider manifests (available providers)
// Fetch provider manifests (available providers with icons)
try {
await _sendCommand('providers/manifests');
_logger.log('✓ Provider manifests loaded');
final manifestResponse = await _sendCommand('providers/manifests');
final manifests = manifestResponse['result'];
if (manifests is List) {
_providerManifests.clear();
for (final manifestData in manifests) {
if (manifestData is Map<String, dynamic>) {
final manifest = ProviderManifest.fromJson(manifestData);
if (manifest.domain.isNotEmpty) {
_providerManifests[manifest.domain] = manifest;
}
}
}
_logger.log('✓ Provider manifests loaded: ${_providerManifests.length} providers with ${_providerManifests.values.where((m) => m.hasSvgIcon).length} icons');
}
} catch (e) {
_logger.log('⚠️ Could not load provider manifests: $e');
}
@@ -3470,6 +3488,18 @@ class MusicAssistantAPI {
}
}
/// Get provider manifest by domain
ProviderManifest? getProviderManifest(String domain) {
return _providerManifests[domain];
}
/// Get SVG icon for a provider domain
/// Returns null if no icon is available
String? getProviderIconSvg(String domain, {bool isDark = false}) {
final manifest = _providerManifests[domain];
return manifest?.getSvgIcon(isDark: isDark);
}
/// Get current authenticated user's profile
/// Returns user info including display_name, username, groups, etc.
Future<Map<String, dynamic>?> getCurrentUserInfo() async {
+24
View File
@@ -92,6 +92,7 @@ class SettingsService {
static const String _keyLastSelectedPlayerId = 'last_selected_player_id';
static const String _keyPreferLocalPlayer = 'prefer_local_player';
static const String _keySmartSortPlayers = 'smart_sort_players';
static const String _keyDisableAutoSwitch = 'disable_auto_switch';
static const String _keyShowRecentAlbums = 'show_recent_albums';
static const String _keyShowDiscoverArtists = 'show_discover_artists';
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
@@ -167,6 +168,7 @@ class SettingsService {
// Hint System Settings
static const String _keyShowHints = 'show_hints'; // Master toggle for hints
static const String _keyShowProviderIcons = 'show_provider_icons'; // Show provider icons on album art
static const String _keyHasUsedPlayerReveal = 'has_used_player_reveal'; // Track if user has pulled to reveal players
static const String _keyHasCompletedOnboarding = 'has_completed_onboarding'; // Track if user has seen welcome screen
@@ -360,6 +362,10 @@ class SettingsService {
static Future<bool> getSmartSortPlayers() => _getBool(_keySmartSortPlayers, defaultValue: false);
static Future<void> setSmartSortPlayers(bool smartSort) => _setBool(_keySmartSortPlayers, smartSort);
// Disable Auto-Switch - prevents automatic switching to playing players
static Future<bool> getDisableAutoSwitch() => _getBool(_keyDisableAutoSwitch, defaultValue: false);
static Future<void> setDisableAutoSwitch(bool disable) => _setBool(_keyDisableAutoSwitch, disable);
// Helper to create player name with possessive apostrophe
// Automatically detects Phone vs Tablet based on screen size
static String _makePlayerName(String ownerName) {
@@ -882,6 +888,24 @@ class SettingsService {
await prefs.setBool(_keyShowHints, show);
}
/// Get whether provider icons are shown on album art (default: true)
static Future<bool> getShowProviderIcons() async {
final prefs = await _getPrefs();
return prefs.getBool(_keyShowProviderIcons) ?? true;
}
/// Synchronous getter for provider icons setting (uses cached prefs)
/// Returns true if prefs not yet initialized
static bool getShowProviderIconsSync() {
return _prefsCache?.getBool(_keyShowProviderIcons) ?? true;
}
/// Set whether provider icons are shown on album art
static Future<void> setShowProviderIcons(bool show) async {
final prefs = await _getPrefs();
await prefs.setBool(_keyShowProviderIcons, show);
}
/// Check if user has ever used the player reveal gesture
static Future<bool> getHasUsedPlayerReveal() async {
final prefs = await _getPrefs();
+61 -60
View File
@@ -10,6 +10,7 @@ import '../constants/timings.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import '../services/metadata_service.dart';
import 'provider_icon.dart';
class AlbumCard extends StatefulWidget {
final Album album;
@@ -40,7 +41,8 @@ class _AlbumCardState extends State<AlbumCard> {
bool _isNavigating = false;
/// Delay before fetching fallback images to avoid requests during fast scroll
static const _fallbackDelay = Duration(milliseconds: 200);
/// PERF: Increased from 200ms to 400ms to reduce network requests during slow scroll
static const _fallbackDelay = Duration(milliseconds: 400);
@override
void initState() {
@@ -123,8 +125,8 @@ class _AlbumCardState extends State<AlbumCard> {
if (_isNavigating) return;
_isNavigating = true;
// Update adaptive colors immediately on tap
updateAdaptiveColorsFromImage(context, imageUrl);
// PERF: Color extraction deferred to detail screen's initState
// to avoid competing with Hero animation for GPU resources
Navigator.push(
context,
FadeSlidePageRoute(
@@ -151,72 +153,71 @@ class _AlbumCardState extends State<AlbumCard> {
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + suffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(12.0),
child: Container(
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) {
// Try fallback on error (only for MA URLs, not fallback URLs)
if (!_maImageFailed && url == maImageUrl) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_onImageError();
});
}
return Icon(
Icons.album_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
);
},
)
: Center(
child: Icon(
Icons.album_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) {
// Try fallback on error (only for MA URLs, not fallback URLs)
if (!_maImageFailed && url == maImageUrl) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_onImageError();
});
}
return Icon(
Icons.album_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
);
},
)
: Center(
child: Icon(
Icons.album_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
// Provider icon overlay
if (widget.album.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.album.providerMappings!.first.providerDomain,
),
],
),
),
),
),
const SizedBox(height: 8),
// Album title with year
Hero(
tag: HeroTags.albumTitle + (widget.album.uri ?? widget.album.itemId) + suffix,
child: Material(
color: Colors.transparent,
child: Text(
widget.album.nameWithYear,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
Text(
widget.album.nameWithYear,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
// Artist name
Hero(
tag: HeroTags.artistName + (widget.album.uri ?? widget.album.itemId) + suffix,
child: Material(
color: Colors.transparent,
child: Text(
widget.album.artistsString,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
Text(
widget.album.artistsString,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withOpacity(0.7),
),
),
],
+14 -18
View File
@@ -42,7 +42,8 @@ class _ArtistCardState extends State<ArtistCard> {
bool _isNavigating = false;
/// Delay before fetching fallback images to avoid requests during fast scroll
static const _fallbackDelay = Duration(milliseconds: 200);
/// PERF: Increased from 200ms to 400ms to reduce network requests during slow scroll
static const _fallbackDelay = Duration(milliseconds: 400);
@override
void initState() {
@@ -116,8 +117,8 @@ class _ArtistCardState extends State<ArtistCard> {
if (_isNavigating) return;
_isNavigating = true;
// Update adaptive colors immediately on tap
updateAdaptiveColorsFromImage(context, imageUrl);
// PERF: Color extraction deferred to detail screen's initState
// to avoid competing with Hero animation for GPU resources
Navigator.push(
context,
FadeSlidePageRoute(
@@ -186,23 +187,18 @@ class _ArtistCardState extends State<ArtistCard> {
),
const SizedBox(height: 8),
// Artist name - fixed height container so image size is consistent
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
SizedBox(
height: 36, // Fixed height for 2 lines of text
child: Hero(
tag: HeroTags.artistName + (widget.artist.uri ?? widget.artist.itemId) + suffix,
child: Material(
color: Colors.transparent,
child: Text(
widget.artist.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
height: 1.15,
),
),
child: Text(
widget.artist.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
height: 1.15,
),
),
),
+6
View File
@@ -8,6 +8,7 @@ import '../services/debug_logger.dart';
import '../utils/page_transitions.dart';
import '../screens/audiobook_detail_screen.dart';
import '../constants/hero_tags.dart';
import 'provider_icon.dart';
class AudiobookRow extends StatefulWidget {
final String title;
@@ -285,6 +286,11 @@ class _AudiobookCard extends StatelessWidget {
),
),
),
// Provider icon overlay
if (audiobook.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: audiobook.providerMappings!.first.providerDomain,
),
],
),
),
+91 -78
View File
@@ -23,6 +23,7 @@ import '../screens/album_details_screen.dart';
import '../utils/page_transitions.dart';
import 'animated_icon_button.dart';
import 'global_player_overlay.dart';
import 'provider_icon.dart';
import 'volume_control.dart';
import 'player/player_widgets.dart';
import 'player/mini_player_content.dart';
@@ -2589,20 +2590,32 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
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),
child: Stack(
fit: StackFit.expand,
children: [
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),
// Provider icon overlay (only show when expanded enough)
if (t > 0.5 && currentTrack.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: currentTrack.providerMappings!.first.providerDomain,
size: 24,
margin: 8,
),
],
),
),
),
),
@@ -3277,7 +3290,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// GPU PERF: Use icon color alpha instead of Opacity
if (t > 0.3)
Positioned(
top: topPadding + 4,
top: topPadding - 4,
left: 4,
child: IconButton(
icon: Icon(
@@ -3294,7 +3307,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
// GPU PERF: Use text color alpha instead of Opacity
if (t > 0.5)
Positioned(
top: topPadding + 12,
top: topPadding + 6,
left: 56,
right: 56,
child: IgnorePointer(
@@ -3302,7 +3315,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
selectedPlayer.name,
style: TextStyle(
color: textColor.withOpacity(0.6 * ((t - 0.5) / 0.5).clamp(0.0, 1.0)),
fontSize: 15,
fontSize: 17,
fontWeight: FontWeight.w500,
letterSpacing: 0.2,
),
@@ -3912,62 +3925,30 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
top: false,
child: Column(
children: [
// Drag handle area (visual only - swipe-to-close handled by outer wrapper)
Column(
children: [
// Drag handle
Center(
child: Container(
margin: const EdgeInsets.only(top: 12, bottom: 8),
width: 36,
height: 4,
decoration: BoxDecoration(
color: textColor.withOpacity(0.3),
borderRadius: BorderRadius.circular(2),
),
),
),
// Header
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 8, 0),
child: Row(
children: [
IconButton(
icon: Icon(Icons.keyboard_arrow_down_rounded, color: textColor, size: 28),
onPressed: _closeLyricsPanel,
padding: const EdgeInsets.all(8),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Lyrics',
style: TextStyle(
color: textColor,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
if (currentTrack != null)
Text(
'${currentTrack.name} • ${currentTrack.artistsString}',
style: TextStyle(
color: textColor.withOpacity(0.6),
fontSize: 13,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
// Header (matches queue and sleep timer style)
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 0),
child: Row(
children: [
IconButton(
icon: Icon(Icons.keyboard_arrow_down_rounded, color: textColor, size: 28),
onPressed: _closeLyricsPanel,
padding: const EdgeInsets.all(8),
),
const SizedBox(width: 8),
Text(
'Lyrics',
style: TextStyle(
color: textColor,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
const SizedBox(width: 16),
],
),
),
const SizedBox(height: 8),
Divider(color: textColor.withOpacity(0.1), height: 1),
// Lyrics content
@@ -4069,16 +4050,48 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
),
)
// Plain lyrics - show as scrollable text
: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Text(
_currentLyrics!,
style: TextStyle(
color: textColor,
fontSize: 16,
height: 1.8,
// Wrapped with Listener for swipe-to-close (always at "top" for plain text)
: Listener(
onPointerDown: (event) {
_lyricsSwipeStart = event.position;
_wasLyricsAtTopOnSwipeStart = true; // Plain lyrics always count as at top
},
onPointerMove: (event) {
if (_lyricsSwipeStart == null) return;
final dy = event.position.dy - _lyricsSwipeStart!.dy;
final dx = (event.position.dx - _lyricsSwipeStart!.dx).abs();
// Only trigger swipe-to-close if swiping down
if (dy > 20 && dy > dx * 1.5 && _wasLyricsAtTopOnSwipeStart && !_isSwipingLyricsContent) {
_isSwipingLyricsContent = true;
}
},
onPointerUp: (event) {
if (_isSwipingLyricsContent && _lyricsSwipeStart != null) {
final dy = event.position.dy - _lyricsSwipeStart!.dy;
if (dy > 50) {
// Calculate velocity (simplified)
final velocity = dy / 200; // Approximate velocity
_closeLyricsPanel(velocity: velocity);
}
}
_lyricsSwipeStart = null;
_isSwipingLyricsContent = false;
},
onPointerCancel: (_) {
_lyricsSwipeStart = null;
_isSwipingLyricsContent = false;
},
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Text(
_currentLyrics!,
style: TextStyle(
color: textColor,
fontSize: 16,
height: 1.8,
),
textAlign: TextAlign.center,
),
textAlign: TextAlign.center,
),
)
: Center(
+8 -10
View File
@@ -623,16 +623,14 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
try {
await widget.maProvider.api?.queueCommandPlayIndex(playerId, item.queueItemId);
} catch (e) {
// Error playing index - handled silently
}
// Clear optimistic state after delay - server state will take over
await Future.delayed(const Duration(milliseconds: 1500));
if (mounted) {
setState(() {
_optimisticCurrentIndex = null;
});
// Error playing index - clear optimistic state
if (mounted) {
setState(() {
_optimisticCurrentIndex = null;
});
}
}
// Note: didUpdateWidget clears _optimisticCurrentIndex when server state catches up
}
void _handleMoveToPlayNext(QueueItem item, int index, bool isCurrentItem) async {
@@ -789,7 +787,7 @@ class _QueuePanelState extends State<QueuePanel> with SingleTickerProviderStateM
onPressed: _showingTransferDropdown ? _closeTransferDropdown : widget.onClose,
padding: Spacing.paddingAll12,
),
const Spacer(),
const SizedBox(width: 8),
Text(
'Queue',
style: TextStyle(
+46 -40
View File
@@ -9,6 +9,7 @@ import '../constants/hero_tags.dart';
import '../constants/timings.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import 'provider_icon.dart';
class PlaylistCard extends StatefulWidget {
final Playlist playlist;
@@ -48,8 +49,8 @@ class _PlaylistCardState extends State<PlaylistCard> {
if (_isNavigating) return;
_isNavigating = true;
// Update adaptive colors immediately on tap
updateAdaptiveColorsFromImage(context, imageUrl);
// PERF: Color extraction deferred to detail screen's initState
// to avoid competing with Hero animation for GPU resources
Navigator.push(
context,
FadeSlidePageRoute(
@@ -76,51 +77,56 @@ class _PlaylistCardState extends State<PlaylistCard> {
tag: HeroTags.playlistCover + (widget.playlist.uri ?? widget.playlist.itemId) + suffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(12.0),
child: Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) => Center(
child: Icon(
Icons.playlist_play_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceContainerHighest,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) => Center(
child: Icon(
Icons.playlist_play_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
Icons.playlist_play_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
)
: Center(
child: Icon(
Icons.playlist_play_rounded,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
// Provider icon overlay
if (widget.playlist.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.playlist.providerMappings!.first.providerDomain,
),
],
),
),
),
),
const SizedBox(height: 8),
// Playlist title
Hero(
tag: HeroTags.playlistTitle + (widget.playlist.uri ?? widget.playlist.itemId) + suffix,
child: Material(
color: Colors.transparent,
child: Text(
widget.playlist.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
Text(
widget.playlist.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
// Owner/track count
+46 -40
View File
@@ -10,6 +10,7 @@ import '../constants/hero_tags.dart';
import '../constants/timings.dart';
import '../theme/theme_provider.dart';
import '../utils/page_transitions.dart';
import 'provider_icon.dart';
class PodcastCard extends StatefulWidget {
final MediaItem podcast;
@@ -54,8 +55,8 @@ class _PodcastCardState extends State<PodcastCard> {
if (_isNavigating) return;
_isNavigating = true;
// Update adaptive colors immediately on tap
updateAdaptiveColorsFromImage(context, imageUrl);
// PERF: Color extraction deferred to detail screen's initState
// to avoid competing with Hero animation for GPU resources
Navigator.push(
context,
FadeSlidePageRoute(
@@ -82,51 +83,56 @@ class _PodcastCardState extends State<PodcastCard> {
tag: HeroTags.podcastCover + (widget.podcast.uri ?? widget.podcast.itemId) + suffix,
child: ClipRRect(
borderRadius: BorderRadius.circular(12.0),
child: Container(
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) => Center(
child: Icon(
MdiIcons.podcast,
size: 64,
color: colorScheme.onSurfaceVariant,
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: colorScheme.surfaceVariant,
child: imageUrl != null
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
memCacheWidth: cacheSize,
memCacheHeight: cacheSize,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
placeholder: (context, url) => const SizedBox(),
errorWidget: (context, url, error) => Center(
child: Icon(
MdiIcons.podcast,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
)
: Center(
child: Icon(
MdiIcons.podcast,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
)
: Center(
child: Icon(
MdiIcons.podcast,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
),
// Provider icon overlay
if (widget.podcast.providerMappings?.isNotEmpty == true)
ProviderIconOverlay(
domain: widget.podcast.providerMappings!.first.providerDomain,
),
],
),
),
),
),
const SizedBox(height: 8),
// Podcast title
Hero(
tag: HeroTags.podcastTitle + (widget.podcast.uri ?? widget.podcast.itemId) + suffix,
child: Material(
color: Colors.transparent,
child: Text(
widget.podcast.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
// PERF: Removed Hero - text animations provide minimal benefit but add overhead
Text(
widget.podcast.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
// Podcast author (if available)
+85
View File
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:provider/provider.dart';
import '../providers/music_assistant_provider.dart';
import '../services/settings_service.dart';
/// Displays a provider icon (SVG) from Music Assistant.
///
/// Shows a small icon in a rounded container, typically used as an overlay
/// on album art to indicate which provider the item comes from.
class ProviderIcon extends StatelessWidget {
/// The provider domain (e.g., "spotify", "tidal", "qobuz")
final String domain;
/// Size of the icon container
final double size;
/// Whether to use dark mode icon variant
final bool? isDark;
const ProviderIcon({
super.key,
required this.domain,
this.size = 20,
this.isDark,
});
@override
Widget build(BuildContext context) {
final maProvider = context.read<MusicAssistantProvider>();
final brightness = Theme.of(context).brightness;
final useDark = isDark ?? brightness == Brightness.dark;
final svgString = maProvider.getProviderIconSvg(domain, isDark: useDark);
if (svgString == null || svgString.isEmpty) {
return const SizedBox.shrink();
}
return SvgPicture.string(
svgString,
width: size,
height: size,
fit: BoxFit.contain,
);
}
}
/// A positioned provider icon overlay for use in Stack widgets.
///
/// Typically positioned in a corner of album art.
class ProviderIconOverlay extends StatelessWidget {
/// The provider domain (e.g., "spotify", "tidal", "qobuz")
final String domain;
/// Size of the icon container
final double size;
/// Position from the edges
final double margin;
const ProviderIconOverlay({
super.key,
required this.domain,
this.size = 20,
this.margin = 4,
});
@override
Widget build(BuildContext context) {
// Check if provider icons are enabled in settings
if (!SettingsService.getShowProviderIconsSync()) {
return const SizedBox.shrink();
}
return Positioned(
right: margin,
bottom: margin,
child: ProviderIcon(
domain: domain,
size: size,
),
);
}
}
+40
View File
@@ -440,6 +440,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
flutter_svg:
dependency: "direct main"
description:
name: flutter_svg
sha256: d44bf546b13025ec7353091516f6881f1d4c633993cb109c3916c3a0159dadf1
url: "https://pub.dev"
source: hosted
version: "2.1.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -714,6 +722,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.0"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider:
dependency: "direct main"
description:
@@ -1127,6 +1143,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.5.2"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de"
url: "https://pub.dev"
source: hosted
version: "1.1.18"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad"
url: "https://pub.dev"
source: hosted
version: "1.1.16"
vector_math:
dependency: transitive
description:
+2 -1
View File
@@ -1,7 +1,7 @@
name: ensemble
description: A minimalistic Music Assistant client built with Flutter
publish_to: 'none'
version: 2.9.0-beta+48
version: 2.9.1-beta+49
environment:
sdk: '>=3.0.0 <4.0.0'
@@ -17,6 +17,7 @@ dependencies:
material_design_icons_flutter: ^7.0.7296
dynamic_color: 1.6.8
palette_generator: ^0.3.3+3
flutter_svg: ^2.0.10+1
# State management
provider: ^6.1.1