- Move palette extraction to isolate using compute() to prevent main thread blocking - Add image package for pure-Dart isolate-compatible image decoding - Add extractColorSchemesFromUrl() with LRU cache for downloaded images - Replace context.watch() with context.select() to reduce unnecessary rebuilds - Update all detail screens to use isolate-based color extraction - Optimize library screens and widgets to only rebuild on specific field changes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
159 lines
5.0 KiB
Dart
159 lines
5.0 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import '../providers/music_assistant_provider.dart';
|
|
import '../models/media_item.dart';
|
|
import '../widgets/common/empty_state.dart';
|
|
import '../l10n/app_localizations.dart';
|
|
|
|
class LibraryTracksScreen extends StatelessWidget {
|
|
const LibraryTracksScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final textTheme = Theme.of(context).textTheme;
|
|
|
|
return Scaffold(
|
|
backgroundColor: colorScheme.background,
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.transparent,
|
|
elevation: 0,
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back_rounded),
|
|
onPressed: () => Navigator.pop(context),
|
|
color: colorScheme.onBackground,
|
|
),
|
|
title: Text(
|
|
S.of(context)!.tracks,
|
|
style: textTheme.headlineSmall?.copyWith(
|
|
color: colorScheme.onBackground,
|
|
fontWeight: FontWeight.w300,
|
|
),
|
|
),
|
|
centerTitle: true,
|
|
),
|
|
body: _buildTracksList(context),
|
|
);
|
|
}
|
|
|
|
Widget _buildTracksList(BuildContext context) {
|
|
// Use select to only rebuild when tracks or isLoading changes
|
|
final (tracks, isLoading) = context.select<MusicAssistantProvider, (List, bool)>(
|
|
(p) => (p.tracks, p.isLoading),
|
|
);
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
// Show cached data immediately if available, even while loading
|
|
// Only show spinner if we have no data at all AND we're loading
|
|
if (tracks.isEmpty && isLoading) {
|
|
return Center(
|
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
|
);
|
|
}
|
|
|
|
if (tracks.isEmpty) {
|
|
return EmptyState.tracks(
|
|
context: context,
|
|
onRefresh: context.read<MusicAssistantProvider>().loadLibrary,
|
|
);
|
|
}
|
|
|
|
return ListView.builder(
|
|
itemCount: tracks.length,
|
|
padding: const EdgeInsets.all(8),
|
|
cacheExtent: 500, // Prebuild items off-screen for smoother scrolling
|
|
addAutomaticKeepAlives: false, // Tiles don't need individual keep-alive
|
|
addRepaintBoundaries: false, // We add RepaintBoundary manually to tiles
|
|
itemBuilder: (context, index) {
|
|
final track = tracks[index] as Track;
|
|
return _buildTrackTile(context, track, index);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildTrackTile(
|
|
BuildContext context,
|
|
Track track,
|
|
int index,
|
|
) {
|
|
final maProvider = context.read<MusicAssistantProvider>();
|
|
final imageUrl = track.album != null
|
|
? maProvider.getImageUrl(track.album!, size: 128)
|
|
: null;
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final textTheme = Theme.of(context).textTheme;
|
|
|
|
return RepaintBoundary(
|
|
child: ListTile(
|
|
key: ValueKey(track.uri ?? track.itemId),
|
|
leading: Container(
|
|
width: 48,
|
|
height: 48,
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceVariant,
|
|
borderRadius: BorderRadius.circular(8),
|
|
image: imageUrl != null
|
|
? DecorationImage(
|
|
image: CachedNetworkImageProvider(imageUrl),
|
|
fit: BoxFit.cover,
|
|
)
|
|
: null,
|
|
),
|
|
child: imageUrl == null
|
|
? Icon(Icons.music_note_rounded, color: colorScheme.onSurfaceVariant)
|
|
: null,
|
|
),
|
|
title: Text(
|
|
track.name,
|
|
style: textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
subtitle: Text(
|
|
track.artistsString,
|
|
style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface.withOpacity(0.7)),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
trailing: track.duration != null
|
|
? Text(
|
|
_formatDuration(track.duration!),
|
|
style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurface.withOpacity(0.54)),
|
|
)
|
|
: null,
|
|
onTap: () => _playTrack(context, maProvider, index),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _playTrack(
|
|
BuildContext context,
|
|
MusicAssistantProvider maProvider,
|
|
int startIndex,
|
|
) async {
|
|
final tracks = maProvider.tracks;
|
|
if (tracks.isEmpty) return;
|
|
|
|
if (maProvider.selectedPlayer == null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Use Music Assistant to play tracks on the selected player
|
|
await maProvider.playTracks(
|
|
maProvider.selectedPlayer!.playerId,
|
|
tracks,
|
|
startIndex: startIndex,
|
|
);
|
|
}
|
|
|
|
String _formatDuration(Duration duration) {
|
|
final minutes = duration.inMinutes;
|
|
final seconds = duration.inSeconds % 60;
|
|
return '$minutes:${seconds.toString().padLeft(2, '0')}';
|
|
}
|
|
}
|