diff --git a/CHANGELOG.md b/CHANGELOG.md
index a62cbf51..2e02b843 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,9 +2,22 @@
## [1.6.2] - 2026-01-02
+### Added
+- **HTTPS-Only Downloads**: APK downloads and update checks now enforce HTTPS-only connections for security
+
### Changed
- **Home Tab Rename**: Renamed "Search" tab to "Home" with home icon
- **Branding**: Changed idle screen title from "Search Music" to "SpotiFLAC"
+- **About Page Redesign**: New Material Expressive 3 grouped layout with app header, contributors section with GitHub avatars, and organized links
+
+### Performance
+- **Optimized State Management**: Use `.select()` for Riverpod providers to prevent unnecessary widget rebuilds
+- **List Keys**: Added keys to all list builders for efficient list updates and reordering
+- **Request Cancellation**: Outdated API requests are ignored when new search/fetch is triggered
+- **Debounced URL Fetches**: All network requests now debounced to prevent rapid duplicate calls
+- **Bounded File Cache**: File existence cache now limited to 500 entries to prevent memory leak
+- **Timer Cleanup**: Progress polling timer properly disposed when provider is destroyed
+- **Stream Error Handling**: Share intent stream now has proper error handling
## [1.6.1] - 2026-01-02
diff --git a/README.md b/README.md
index 93e4191c..44fc1f7a 100644
--- a/README.md
+++ b/README.md
@@ -15,16 +15,6 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
-## Features
-
-- Download tracks, albums, and playlists from Spotify links
-- True lossless FLAC quality from Tidal, Qobuz & Amazon Music
-- Material Expressive 3 design with dynamic colors
-- High performance rendering with Impeller (Vulkan)
-- Concurrent downloads up to 3 simultaneous
-- Real-time download progress tracking
-- Download notifications
-
## Screenshots
diff --git a/devtools_options.yaml b/devtools_options.yaml
new file mode 100644
index 00000000..fa0b357c
--- /dev/null
+++ b/devtools_options.yaml
@@ -0,0 +1,3 @@
+description: This file stores settings for Dart & Flutter DevTools.
+documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
+extensions:
diff --git a/go_backend/spotify.go b/go_backend/spotify.go
index ed5b49e6..ca553282 100644
--- a/go_backend/spotify.go
+++ b/go_backend/spotify.go
@@ -10,6 +10,7 @@ import (
"math/rand"
"net/http"
"net/url"
+ "os"
"strings"
"sync"
"time"
@@ -34,6 +35,7 @@ type SpotifyMetadataClient struct {
clientSecret string
cachedToken string
tokenExpiresAt time.Time
+ tokenMu sync.Mutex // Protects token cache for concurrent access
rng *rand.Rand
rngMu sync.Mutex
userAgent string
@@ -43,19 +45,23 @@ type SpotifyMetadataClient struct {
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
src := rand.NewSource(time.Now().UnixNano())
- // Decode credentials from base64
- clientID := ""
- if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
- clientID = string(decoded)
+ // Prefer environment variables for credentials (more secure), fall back to built-in
+ clientID := os.Getenv("SPOTIFY_CLIENT_ID")
+ if clientID == "" {
+ if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
+ clientID = string(decoded)
+ }
}
- clientSecret := ""
- if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
- clientSecret = string(decoded)
+ clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
+ if clientSecret == "" {
+ if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
+ clientSecret = string(decoded)
+ }
}
c := &SpotifyMetadataClient{
- httpClient: &http.Client{Timeout: 15 * time.Second},
+ httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
clientID: clientID,
clientSecret: clientSecret,
rng: rand.New(src),
diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart
index fb68b050..ac22244e 100644
--- a/lib/providers/download_queue_provider.dart
+++ b/lib/providers/download_queue_provider.dart
@@ -249,6 +249,12 @@ class DownloadQueueNotifier extends Notifier {
@override
DownloadQueueState build() {
+ // Cleanup timer when provider is disposed
+ ref.onDispose(() {
+ _progressTimer?.cancel();
+ _progressTimer = null;
+ });
+
// Initialize output directory and load persisted queue asynchronously
Future.microtask(() async {
await _initOutputDir();
diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart
index 638f7190..d2b4b3a0 100644
--- a/lib/providers/track_provider.dart
+++ b/lib/providers/track_provider.dart
@@ -81,12 +81,21 @@ class ArtistAlbum {
}
class TrackNotifier extends Notifier {
+ /// Request ID to track and cancel outdated requests
+ int _currentRequestId = 0;
+
@override
TrackState build() {
return const TrackState();
}
+ /// Check if request is still valid (not cancelled by newer request)
+ bool _isRequestValid(int requestId) => requestId == _currentRequestId;
+
Future fetchFromUrl(String url) async {
+ // Increment request ID to cancel any pending requests
+ final requestId = ++_currentRequestId;
+
// Save current state for back navigation (only if we have content or it's empty)
final savedState = state.hasContent ? TrackState(
tracks: state.tracks,
@@ -102,9 +111,12 @@ class TrackNotifier extends Notifier {
try {
final parsed = await PlatformBridge.parseSpotifyUrl(url);
+ if (!_isRequestValid(requestId)) return; // Request cancelled
+
final type = parsed['type'] as String;
final metadata = await PlatformBridge.getSpotifyMetadata(url);
+ if (!_isRequestValid(requestId)) return; // Request cancelled
if (type == 'track') {
final trackData = metadata['track'] as Map;
@@ -152,11 +164,15 @@ class TrackNotifier extends Notifier {
);
}
} catch (e) {
+ if (!_isRequestValid(requestId)) return; // Request cancelled
state = TrackState(isLoading: false, error: e.toString(), previousState: savedState);
}
}
Future search(String query) async {
+ // Increment request ID to cancel any pending requests
+ final requestId = ++_currentRequestId;
+
// Save current state for back navigation
final savedState = state.hasContent ? TrackState(
tracks: state.tracks,
@@ -172,6 +188,8 @@ class TrackNotifier extends Notifier {
try {
final results = await PlatformBridge.searchSpotify(query, limit: 20);
+ if (!_isRequestValid(requestId)) return; // Request cancelled
+
final trackList = results['tracks'] as List? ?? [];
final tracks = trackList.map((t) => _parseSearchTrack(t as Map)).toList();
state = TrackState(
@@ -180,6 +198,7 @@ class TrackNotifier extends Notifier {
previousState: savedState,
);
} catch (e) {
+ if (!_isRequestValid(requestId)) return; // Request cancelled
state = TrackState(isLoading: false, error: e.toString(), previousState: savedState);
}
}
@@ -242,6 +261,9 @@ class TrackNotifier extends Notifier {
/// Fetch album from artist view - saves current artist state for back navigation
Future fetchAlbumFromArtist(String albumId) async {
+ // Increment request ID to cancel any pending requests
+ final requestId = ++_currentRequestId;
+
// Save current artist state before fetching album
final savedState = TrackState(
artistName: state.artistName,
@@ -258,6 +280,7 @@ class TrackNotifier extends Notifier {
try {
final url = 'https://open.spotify.com/album/$albumId';
final metadata = await PlatformBridge.getSpotifyMetadata(url);
+ if (!_isRequestValid(requestId)) return; // Request cancelled
final albumInfo = metadata['album_info'] as Map;
final trackList = metadata['track_list'] as List;
@@ -271,6 +294,7 @@ class TrackNotifier extends Notifier {
previousState: savedState,
);
} catch (e) {
+ if (!_isRequestValid(requestId)) return; // Request cancelled
state = TrackState(
isLoading: false,
error: e.toString(),
diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart
index 7e5f7abf..7e8e0995 100644
--- a/lib/screens/home_tab.dart
+++ b/lib/screens/home_tab.dart
@@ -3,6 +3,7 @@ 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/models/track.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
@@ -74,16 +75,14 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient
});
}
- // Don't live search for URLs - wait for submit
- if (text.startsWith('http') || text.startsWith('spotify:')) {
- _debounce?.cancel();
- return;
- }
-
- // Debounce search queries
+ // Debounce all requests (URLs and searches)
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 400), () {
- if (text.length >= 2) {
+ if (text.isEmpty) return;
+
+ if (text.startsWith('http') || text.startsWith('spotify:')) {
+ _fetchMetadata();
+ } else if (text.length >= 2) {
_performSearch(text);
}
});
@@ -196,12 +195,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient
);
}
- bool get _hasResults {
- final trackState = ref.watch(trackProvider);
- // Show results view when typing, loading, or has results
- return _isTyping || trackState.tracks.isNotEmpty || trackState.artistAlbums != null || trackState.isLoading;
- }
-
@override
Widget build(BuildContext context) {
super.build(context);
@@ -209,11 +202,21 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient
// Listen for state changes to sync search bar
ref.listen(trackProvider, _onTrackStateChanged);
- final trackState = ref.watch(trackProvider);
+ // Use select() to only rebuild when specific fields change
+ final tracks = ref.watch(trackProvider.select((s) => s.tracks));
+ final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
+ final error = ref.watch(trackProvider.select((s) => s.error));
+ final albumName = ref.watch(trackProvider.select((s) => s.albumName));
+ final playlistName = ref.watch(trackProvider.select((s) => s.playlistName));
+ final artistName = ref.watch(trackProvider.select((s) => s.artistName));
+ final coverUrl = ref.watch(trackProvider.select((s) => s.coverUrl));
+ final artistAlbums = ref.watch(trackProvider.select((s) => s.artistAlbums));
+ final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore));
+
final colorScheme = Theme.of(context).colorScheme;
- final hasResults = _hasResults;
+ final hasResults = _isTyping || tracks.isNotEmpty || artistAlbums != null || isLoading;
final screenHeight = MediaQuery.of(context).size.height;
- final historyItems = ref.watch(downloadHistoryProvider).items;
+ final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
return Scaffold(
body: CustomScrollView(
@@ -296,7 +299,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient
? const SizedBox.shrink()
: Column(
children: [
- if (!ref.watch(settingsProvider).hasSearchedBefore)
+ if (!hasSearchedBefore)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
@@ -318,7 +321,18 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient
),
// Results content - always in tree
- ..._buildResultsContent(trackState, colorScheme, hasResults),
+ ..._buildResultsContentOptimized(
+ tracks: tracks,
+ isLoading: isLoading,
+ error: error,
+ albumName: albumName,
+ playlistName: playlistName,
+ artistName: artistName,
+ coverUrl: coverUrl,
+ artistAlbums: artistAlbums,
+ colorScheme: colorScheme,
+ hasResults: hasResults,
+ ),
],
),
);
@@ -346,42 +360,45 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient
itemCount: displayItems.length,
itemBuilder: (context, index) {
final item = displayItems[index];
- return GestureDetector(
- onTap: () => _navigateToMetadataScreen(item),
- child: Container(
- width: 60,
- margin: const EdgeInsets.only(right: 12),
- child: Column(
- children: [
- ClipRRect(
- borderRadius: BorderRadius.circular(8),
- child: item.coverUrl != null
- ? CachedNetworkImage(
- imageUrl: item.coverUrl!,
- width: 56,
- height: 56,
- fit: BoxFit.cover,
- memCacheWidth: 112,
- memCacheHeight: 112,
- )
- : Container(
- width: 56,
- height: 56,
- color: colorScheme.surfaceContainerHighest,
- child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24),
- ),
- ),
- const SizedBox(height: 4),
- Text(
- item.trackName,
- style: Theme.of(context).textTheme.labelSmall?.copyWith(
- color: colorScheme.onSurfaceVariant,
+ return KeyedSubtree(
+ key: ValueKey(item.id),
+ child: GestureDetector(
+ onTap: () => _navigateToMetadataScreen(item),
+ child: Container(
+ width: 60,
+ margin: const EdgeInsets.only(right: 12),
+ child: Column(
+ children: [
+ ClipRRect(
+ borderRadius: BorderRadius.circular(8),
+ child: item.coverUrl != null
+ ? CachedNetworkImage(
+ imageUrl: item.coverUrl!,
+ width: 56,
+ height: 56,
+ fit: BoxFit.cover,
+ memCacheWidth: 112,
+ memCacheHeight: 112,
+ )
+ : Container(
+ width: 56,
+ height: 56,
+ color: colorScheme.surfaceContainerHighest,
+ child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24),
+ ),
),
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- textAlign: TextAlign.center,
- ),
- ],
+ const SizedBox(height: 4),
+ Text(
+ item.trackName,
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ textAlign: TextAlign.center,
+ ),
+ ],
+ ),
),
),
);
@@ -401,8 +418,19 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient
));
}
- // Results content slivers (without app bar and search bar)
- List _buildResultsContent(TrackState trackState, ColorScheme colorScheme, bool hasResults) {
+ // Results content slivers (without app bar and search bar) - optimized version
+ List _buildResultsContentOptimized({
+ required List