perf: replace PaletteService with blurred cover background, bump v3.5.1

This commit is contained in:
zarzet
2026-02-07 16:15:21 +07:00
parent c167aa0522
commit 35f412dbd2
10 changed files with 167 additions and 284 deletions
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## [3.5.1] - 2026-02-07
### Performance
- Removed PaletteService (palette_generator) from all screens for faster navigation and reduced memory usage
- Album, Playlist, Downloaded Album, Local Album, and Track Metadata screens now use blurred cover art as header background instead of dominant color extraction
- Removed `palette_generator` dependency
---
## [3.5.0] - 2026-02-07
### Highlights
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.5.0';
static const String buildNumber = '74';
static const String version = '3.5.1';
static const String buildNumber = '75';
static const String fullVersion = '$version+$buildNumber';
+27 -25
View File
@@ -1,7 +1,7 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
@@ -69,7 +69,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
List<Track>? _tracks;
bool _isLoading = false;
String? _error;
Color? _dominantColor;
bool _showTitleInAppBar = false;
String? _artistId;
final ScrollController _scrollController = ScrollController();
@@ -103,8 +102,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
if (_tracks == null || _tracks!.isEmpty) {
_fetchTracks();
}
_extractDominantColor();
}
@override
@@ -121,14 +118,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
}
Future<void> _extractDominantColor() async {
if (widget.coverUrl == null) return;
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
if (mounted && color != null) {
setState(() => _dominantColor = color);
}
}
String _formatReleaseDate(String date) {
if (date.length >= 10) {
final parts = date.substring(0, 10).split('-');
@@ -232,7 +221,6 @@ Future<void> _fetchTracks() async {
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5;
final bgColor = _dominantColor ?? colorScheme.surface;
return SliverAppBar(
expandedHeight: 320,
@@ -264,18 +252,32 @@ Future<void> _fetchTracks() async {
background: Stack(
fit: StackFit.expand,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
bgColor,
bgColor.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.6, 1.0],
// Blurred cover background
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(color: colorScheme.surface),
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
)
else
Container(color: colorScheme.surface),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(color: colorScheme.surface.withValues(alpha: 0.4)),
),
),
Positioned(
left: 0, right: 0, bottom: 0, height: 80,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [colorScheme.surface.withValues(alpha: 0.0), colorScheme.surface],
),
),
),
),
+27 -40
View File
@@ -1,9 +1,9 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
@@ -29,7 +29,6 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget {
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
Color? _dominantColor;
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
@@ -37,7 +36,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_extractDominantColor();
}
@override
@@ -54,29 +52,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
}
Future<void> _extractDominantColor() async {
if (widget.coverUrl == null || widget.coverUrl!.isEmpty) return;
// Check cache first (instant)
final cached = PaletteService.instance.getCached(widget.coverUrl);
if (cached != null) {
if (mounted && cached != _dominantColor) {
setState(() {
_dominantColor = cached;
});
}
return;
}
// Extract in isolate (non-blocking)
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
if (mounted && color != null && color != _dominantColor) {
setState(() {
_dominantColor = color;
});
}
}
/// Get tracks for this album from history provider (reactive)
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
return allItems.where((item) {
@@ -294,7 +269,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; // 50% of screen width
final bgColor = _dominantColor ?? colorScheme.surface;
return SliverAppBar(
expandedHeight: 320,
@@ -326,19 +300,32 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
background: Stack(
fit: StackFit.expand,
children: [
// Background with dominant color
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
bgColor,
bgColor.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.6, 1.0],
// Blurred cover background
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(color: colorScheme.surface),
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
)
else
Container(color: colorScheme.surface),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(color: colorScheme.surface.withValues(alpha: 0.4)),
),
),
Positioned(
left: 0, right: 0, bottom: 0, height: 80,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [colorScheme.surface.withValues(alpha: 0.0), colorScheme.surface],
),
),
),
),
+25 -42
View File
@@ -1,11 +1,11 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
/// Screen to display tracks from a local library album
@@ -30,7 +30,6 @@ class LocalAlbumScreen extends ConsumerStatefulWidget {
class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
Color? _dominantColor;
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
late List<LocalLibraryItem> _sortedTracksCache;
@@ -43,13 +42,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
super.initState();
_scrollController.addListener(_onScroll);
_rebuildTrackCaches();
final cachedColor = PaletteService.instance.getCached(widget.coverPath);
if (cachedColor != null) {
_dominantColor = cachedColor;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_extractDominantColor();
});
}
@override
@@ -59,13 +51,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
oldWidget.tracks.length != widget.tracks.length) {
_rebuildTrackCaches();
}
if (oldWidget.coverPath != widget.coverPath) {
final cachedColor = PaletteService.instance.getCached(widget.coverPath);
if (cachedColor != null && cachedColor != _dominantColor) {
_dominantColor = cachedColor;
}
_extractDominantColor();
}
}
@override
@@ -82,18 +67,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
}
}
Future<void> _extractDominantColor() async {
if (widget.coverPath == null || widget.coverPath!.isEmpty) return;
// Extract color from local file
final color = await PaletteService.instance.extractDominantColorFromFile(widget.coverPath!);
if (mounted && color != null && color != _dominantColor) {
setState(() {
_dominantColor = color;
});
}
}
List<LocalLibraryItem> _buildSortedTracks() {
final tracks = List<LocalLibraryItem>.from(widget.tracks);
tracks.sort((a, b) {
@@ -289,7 +262,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5;
final bgColor = _dominantColor ?? colorScheme.surface;
return SliverAppBar(
expandedHeight: 320,
@@ -321,19 +293,30 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
background: Stack(
fit: StackFit.expand,
children: [
// Background with dominant color
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
bgColor,
bgColor.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.6, 1.0],
// Blurred cover background
if (widget.coverPath != null)
Image.file(
File(widget.coverPath!),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
)
else
Container(color: colorScheme.surface),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(color: colorScheme.surface.withValues(alpha: 0.4)),
),
),
Positioned(
left: 0, right: 0, bottom: 0, height: 80,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [colorScheme.surface.withValues(alpha: 0.0), colorScheme.surface],
),
),
),
),
+27 -25
View File
@@ -1,7 +1,7 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
@@ -32,7 +32,6 @@ class PlaylistScreen extends ConsumerStatefulWidget {
}
class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Color? _dominantColor;
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
List<Track>? _fetchedTracks;
@@ -45,7 +44,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_extractDominantColor();
_fetchTracksIfNeeded();
}
@@ -122,14 +120,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
}
}
Future<void> _extractDominantColor() async {
if (widget.coverUrl == null) return;
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
if (mounted && color != null) {
setState(() => _dominantColor = color);
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
@@ -151,7 +141,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; // 50% of screen width
final bgColor = _dominantColor ?? colorScheme.surface;
return SliverAppBar(
expandedHeight: 320,
@@ -183,19 +172,32 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
background: Stack(
fit: StackFit.expand,
children: [
// Background with dominant color
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
bgColor,
bgColor.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.6, 1.0],
// Blurred cover background
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(color: colorScheme.surface),
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
)
else
Container(color: colorScheme.surface),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(color: colorScheme.surface.withValues(alpha: 0.4)),
),
),
Positioned(
left: 0, right: 0, bottom: 0, height: 80,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [colorScheme.surface.withValues(alpha: 0.0), colorScheme.surface],
),
),
),
),
+48 -50
View File
@@ -1,11 +1,11 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:share_plus/share_plus.dart';
@@ -31,7 +31,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String? _rawLyrics; // Raw LRC with timestamps for embedding
bool _lyricsLoading = false;
String? _lyricsError;
Color? _dominantColor;
bool _showTitleInAppBar = false;
bool _lyricsEmbedded = false; // Track if lyrics are embedded in file
bool _isEmbedding = false; // Track embed operation in progress
@@ -69,10 +68,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
super.initState();
_scrollController.addListener(_onScroll);
_checkFile();
// Delay palette extraction to avoid jitter during initial build
WidgetsBinding.instance.addPostFrameCallback((_) {
_extractDominantColor();
});
}
@override
@@ -89,35 +84,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
}
Future<void> _extractDominantColor() async {
// For local items with cover path, extract from file
if (_isLocalItem && _localCoverPath != null && _localCoverPath!.isNotEmpty) {
final color = await PaletteService.instance.extractDominantColorFromFile(_localCoverPath!);
if (mounted && color != null && color != _dominantColor) {
setState(() => _dominantColor = color);
}
return;
}
final coverUrl = _coverUrl;
if (coverUrl == null) return;
// Check cache first
final cachedColor = PaletteService.instance.getCached(coverUrl);
if (cachedColor != null) {
if (mounted && cachedColor != _dominantColor) {
setState(() => _dominantColor = cachedColor);
}
return;
}
// Extract using PaletteService (runs in isolate)
final color = await PaletteService.instance.extractDominantColor(coverUrl);
if (mounted && color != null && color != _dominantColor) {
setState(() => _dominantColor = color);
}
}
Future<void> _checkFile() async {
var filePath = _filePath;
if (filePath.startsWith('EXISTS:')) {
@@ -184,7 +150,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final colorScheme = Theme.of(context).colorScheme;
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5;
final bgColor = _dominantColor ?? colorScheme.surface;
return Scaffold(
body: CustomScrollView(
@@ -217,7 +182,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
background: _buildHeaderBackground(context, colorScheme, coverSize, bgColor, showContent),
background: _buildHeaderBackground(context, colorScheme, coverSize, showContent),
stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
@@ -285,26 +250,59 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
);
}
Widget _buildHeaderBackground(BuildContext context, ColorScheme colorScheme, double coverSize, Color bgColor, bool showContent) {
Widget _buildHeaderBackground(BuildContext context, ColorScheme colorScheme, double coverSize, bool showContent) {
return Stack(
fit: StackFit.expand,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
bgColor,
bgColor.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.6, 1.0],
// Blurred cover art background
if (_coverUrl != null)
CachedNetworkImage(
imageUrl: _coverUrl!,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(color: colorScheme.surface),
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
)
else if (_localCoverPath != null && _localCoverPath!.isNotEmpty)
Image.file(
File(_localCoverPath!),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
)
else
Container(color: colorScheme.surface),
// Blur filter
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
color: colorScheme.surface.withValues(alpha: 0.4),
),
),
),
// Bottom fade to surface
Positioned(
left: 0,
right: 0,
bottom: 0,
height: 80,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withValues(alpha: 0.0),
colorScheme.surface,
],
),
),
),
),
// Cover art
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
-90
View File
@@ -1,90 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart';
/// Service for extracting dominant colors from images
/// Uses caching to avoid re-extraction and small image size for speed
class PaletteService {
static final PaletteService instance = PaletteService._();
PaletteService._();
final Map<String, Color> _colorCache = {};
/// Extract dominant color from a network image URL
/// Uses small image size and limited colors for speed
Future<Color?> extractDominantColor(String? imageUrl) async {
if (imageUrl == null || imageUrl.isEmpty) return null;
if (!imageUrl.startsWith('http://') && !imageUrl.startsWith('https://')) {
return null;
}
final cached = _colorCache[imageUrl];
if (cached != null) {
return cached;
}
try {
final paletteGenerator = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(imageUrl),
size: const Size(64, 64),
maximumColorCount: 8,
);
final color = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
if (color != null) {
_colorCache[imageUrl] = color;
}
return color;
} catch (e) {
debugPrint('PaletteService error: $e');
return null;
}
}
Future<Color?> extractDominantColorFromFile(String? filePath) async {
if (filePath == null || filePath.isEmpty) return null;
final cached = _colorCache[filePath];
if (cached != null) {
return cached;
}
try {
final file = File(filePath);
if (!await file.exists()) return null;
final paletteGenerator = await PaletteGenerator.fromImageProvider(
FileImage(file),
size: const Size(64, 64),
maximumColorCount: 8,
);
final color = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
if (color != null) {
_colorCache[filePath] = color;
}
return color;
} catch (e) {
debugPrint('PaletteService file error: $e');
return null;
}
}
void clearCache() {
_colorCache.clear();
}
Color? getCached(String? imageUrl) {
if (imageUrl == null) return null;
return _colorCache[imageUrl];
}
}
-8
View File
@@ -741,14 +741,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
palette_generator:
dependency: "direct main"
description:
name: palette_generator
sha256: "4420f7ccc3f0a4a906144e73f8b6267cd940b64f57a7262e95cb8cec3a8ae0ed"
url: "https://pub.dev"
source: hosted
version: "0.3.3+7"
path:
dependency: "direct main"
description:
+1 -2
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none"
version: 3.5.0+74
version: 3.5.1+75
environment:
sdk: ^3.10.0
@@ -43,7 +43,6 @@ dependencies:
# Material Expressive 3 / Dynamic Color
dynamic_color: ^1.7.0
material_color_utilities: ^0.11.1
palette_generator: ^0.3.3+4
# Permissions
permission_handler: ^12.0.1