fix: back gesture freeze on Android 13+ and add album folder structure setting

- Add PopScope with canPop:true to all settings pages for predictive back gesture support
- Change settings navigation to use PageRouteBuilder instead of MaterialPageRoute
- Add album folder structure setting (artist_album vs album_only)
- Fix extension search result parsing to handle both array and object formats
- Update CHANGELOG

Fixes back gesture freeze issue on OnePlus and other Android 13+ devices with gesture navigation
This commit is contained in:
zarzet 2026-01-13 23:48:02 +07:00
parent e049f9b868
commit 15acf181d1
17 changed files with 254 additions and 65 deletions

View file

@ -2,12 +2,46 @@
## [3.0.0-beta.2] - 2026-01-13
### Added
- **Album Folder Structure Setting**: Option to remove artist folder from album path
- New setting in Download Settings when "Separate Singles Folder" is enabled
- `Artist / Album` (default): `Albums/Artist Name/Album Name/`
- `Album Only`: `Albums/Album Name/`
- Requested by user who prefers flat album organization
### Fixed
- **Back Gesture Freeze on OnePlus/Android 13+**: Fixed app freeze when using back gesture in settings
- Added `PopScope` with `canPop: true` to all settings pages
- Changed navigation to use `PageRouteBuilder` with proper slide transition
- Fixes predictive back gesture conflict on devices with gesture navigation
- Affected pages: Download, Appearance, Options, Extensions, About, Logs, Extension Detail
- **Extension Search Result Parsing**: Fixed "cannot unmarshal array into Go value" error
- Go backend now handles both array and object formats from extensions
- Extensions returning `[{track}, {track}]` now work correctly
- Extensions returning `{tracks: [...], total: N}` still work as before
- **Max Resolution Cover Download**: Fixed cover not upgrading to max resolution on mobile
- Added missing `spotifySize300` constant (300x300 size code)
- Mobile now correctly upgrades 300x300 → 640x640 → max resolution (~2000x2000)
- Matches PC version behavior when "Download max resolution song cover" is enabled
- Added `_upgradeToMaxQualityCover()` helper in Flutter for M4A conversion path
- Go backend `cover.go` now directly replaces URL without HEAD verification
- **Extension Search Provider Reset**: Fixed search provider not resetting to default when disabled
- `copyWith` in `AppSettings` couldn't set `searchProvider` to `null`
- Added `clearSearchProvider` boolean parameter to properly clear the value
- Settings menu now correctly switches back to default provider
- **Extension Disabled Search Fallback**: Fixed error when extension is disabled but still called
- `_performSearch` now checks if extension is still enabled before calling custom search
- Automatically falls back to Deezer/Spotify search if extension was disabled
- Clears `searchProvider` setting if extension no longer available
- **Store Tab Unmount Crash**: Fixed "Using ref when widget is unmounted" error
- Added `mounted` check after async operation in `_initialize()`
- Prevents crash when navigating away from Store tab during initialization
- **EXISTS: Prefix in File Path**: Fixed "File not found" error in metadata screen after download
- Duplicate detection was adding `EXISTS:` prefix to file paths

View file

@ -91,7 +91,8 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
}
// upgradeToMaxQuality upgrades Spotify cover URL to maximum quality
// Uses same logic as PC version - replaces 640x640 size code with max resolution
// Same logic as PC version - directly replaces 640x640 size code with max resolution
// No HEAD verification needed - Spotify CDN always serves max resolution if available
func upgradeToMaxQuality(coverURL string) string {
// Spotify image URLs can be upgraded by changing the size parameter
// Format: https://i.scdn.co/image/ab67616d0000b273...
@ -99,21 +100,7 @@ func upgradeToMaxQuality(coverURL string) string {
// ab67616d000082c1 = Max resolution (~2000x2000)
if strings.Contains(coverURL, spotifySize640) {
// Try max resolution first
maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
// Verify max resolution URL is available
client := NewHTTPClientWithTimeout(DefaultTimeout)
req, err := http.NewRequest("HEAD", maxURL, nil)
if err == nil {
resp, err := DoRequestWithUserAgent(client, req)
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return maxURL
}
}
}
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
}
return coverURL

View file

@ -162,8 +162,19 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
}
var searchResult ExtSearchResult
// Try to parse as ExtSearchResult object first
if err := json.Unmarshal(jsonBytes, &searchResult); err != nil {
return nil, fmt.Errorf("failed to parse search result: %w", err)
// If that fails, try parsing as array of tracks directly
var tracks []ExtTrackMetadata
if arrErr := json.Unmarshal(jsonBytes, &tracks); arrErr != nil {
return nil, fmt.Errorf("failed to parse search result: %w (also tried array: %v)", err, arrErr)
}
// Wrap array in ExtSearchResult
searchResult = ExtSearchResult{
Tracks: tracks,
Total: len(tracks),
}
}
// Set provider ID on all tracks

View file

@ -28,6 +28,7 @@ class AppSettings {
final bool useExtensionProviders; // Use extension providers for downloads when available
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID
final bool separateSingles; // Separate singles/EPs into their own folder
final String albumFolderStructure; // artist_album or album_only
final bool showExtensionStore; // Show Extension Store tab in navigation
const AppSettings({
@ -55,6 +56,7 @@ class AppSettings {
this.useExtensionProviders = true, // Default: use extensions when available
this.searchProvider, // Default: null (use Deezer/Spotify)
this.separateSingles = false, // Default: disabled
this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album
this.showExtensionStore = true, // Default: show store
});
@ -82,7 +84,9 @@ class AppSettings {
bool? enableLogging,
bool? useExtensionProviders,
String? searchProvider,
bool clearSearchProvider = false, // Set to true to clear searchProvider to null
bool? separateSingles,
String? albumFolderStructure,
bool? showExtensionStore,
}) {
return AppSettings(
@ -108,8 +112,9 @@ class AppSettings {
metadataSource: metadataSource ?? this.metadataSource,
enableLogging: enableLogging ?? this.enableLogging,
useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders,
searchProvider: searchProvider ?? this.searchProvider,
searchProvider: clearSearchProvider ? null : (searchProvider ?? this.searchProvider),
separateSingles: separateSingles ?? this.separateSingles,
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
);
}

View file

@ -32,6 +32,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
searchProvider: json['searchProvider'] as String?,
separateSingles: json['separateSingles'] as bool? ?? false,
albumFolderStructure: json['albumFolderStructure'] as String? ?? 'artist_album',
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
);
@ -61,5 +62,6 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'useExtensionProviders': instance.useExtensionProviders,
'searchProvider': instance.searchProvider,
'separateSingles': instance.separateSingles,
'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore,
};

View file

@ -669,7 +669,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
/// Build output directory based on folder organization setting and separateSingles
Future<String> _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false}) async {
Future<String> _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false, String albumFolderStructure = 'artist_album'}) async {
String baseDir = state.outputDir;
// If separateSingles is enabled, use Albums/Singles structure
@ -686,10 +686,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
return singlesPath;
} else {
// Albums go to Albums/Artist/Album structure
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
// Albums folder structure based on setting
final albumName = _sanitizeFolderName(track.albumName);
final albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
String albumPath;
if (albumFolderStructure == 'album_only') {
// Albums/Album structure (no artist folder)
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$albumName';
} else {
// Albums/Artist/Album structure (default)
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
}
final dir = Directory(albumPath);
if (!await dir.exists()) {
await dir.create(recursive: true);
@ -1001,13 +1010,42 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
/// Upgrade Spotify cover URL to max quality (~2000x2000)
/// Same logic as Go backend cover.go
String _upgradeToMaxQualityCover(String coverUrl) {
const spotifySize300 = 'ab67616d00001e02'; // 300x300 (small)
const spotifySize640 = 'ab67616d0000b273'; // 640x640 (medium)
const spotifySizeMax = 'ab67616d000082c1'; // Max resolution (~2000x2000)
// First upgrade small (300) to medium (640)
var result = coverUrl;
if (result.contains(spotifySize300)) {
result = result.replaceFirst(spotifySize300, spotifySize640);
}
// Then upgrade medium (640) to max
if (result.contains(spotifySize640)) {
result = result.replaceFirst(spotifySize640, spotifySizeMax);
}
return result;
}
/// Embed metadata and cover to a FLAC file after M4A conversion
Future<void> _embedMetadataAndCover(String flacPath, Track track) async {
final settings = ref.read(settingsProvider);
// Download cover first
String? coverPath;
final coverUrl = track.coverUrl;
var coverUrl = track.coverUrl;
if (coverUrl != null && coverUrl.isNotEmpty) {
try {
// Upgrade cover URL to max quality if setting is enabled
if (settings.maxQualityCover) {
coverUrl = _upgradeToMaxQualityCover(coverUrl);
_log.d('Cover URL upgraded to max quality: $coverUrl');
}
final tempDir = await getTemporaryDirectory();
final uniqueId =
'${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
@ -1446,6 +1484,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
trackToDownload,
settings.folderOrganization,
separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure,
);
// Use quality override if set, otherwise use default from settings

View file

@ -196,7 +196,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
void setSearchProvider(String? provider) {
state = state.copyWith(searchProvider: provider);
if (provider == null || provider.isEmpty) {
state = state.copyWith(clearSearchProvider: true);
} else {
state = state.copyWith(searchProvider: provider);
}
_saveSettings();
}
@ -217,6 +221,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setAlbumFolderStructure(String structure) {
state = state.copyWith(albumFolderStructure: structure);
_saveSettings();
}
void setShowExtensionStore(bool enabled) {
state = state.copyWith(showExtensionStore: enabled);
_saveSettings();

View file

@ -81,6 +81,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
Future<void> _performSearch(String query) async {
final settings = ref.read(settingsProvider);
final extState = ref.read(extensionProvider);
final searchProvider = settings.searchProvider;
// Skip if same query already searched with same provider
@ -88,11 +89,20 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
if (_lastSearchQuery == searchKey) return;
_lastSearchQuery = searchKey;
if (searchProvider != null && searchProvider.isNotEmpty) {
// Check if extension search provider is set AND still enabled
final isExtensionEnabled = searchProvider != null &&
searchProvider.isNotEmpty &&
extState.extensions.any((e) => e.id == searchProvider && e.enabled);
if (isExtensionEnabled) {
// Use custom search from extension
await ref.read(trackProvider.notifier).customSearch(searchProvider, query);
} else {
// Use default search (Deezer/Spotify)
// Also clear searchProvider if it was set but extension is disabled
if (searchProvider != null && searchProvider.isNotEmpty && !isExtensionEnabled) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
}
await ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource);
}
ref.read(settingsProvider.notifier).setHasSearchedBefore();

View file

@ -12,11 +12,13 @@ class AboutPage extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
return PopScope(
canPop: true, // Always allow back gesture
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
@ -218,6 +220,7 @@ class AboutPage extends StatelessWidget {
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
),
),
);
}

View file

@ -14,11 +14,13 @@ class AppearanceSettingsPage extends ConsumerWidget {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
return PopScope(
canPop: true, // Always allow back gesture
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
@ -129,7 +131,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
),
],
),
);
),
);
}
}

View file

@ -22,11 +22,13 @@ class DownloadSettingsPage extends ConsumerWidget {
// Check if current service is built-in (supports quality options)
final isBuiltInService = _builtInServices.contains(settings.defaultService);
return Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
return PopScope(
canPop: true, // Always allow back gesture
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
@ -194,6 +196,19 @@ class DownloadSettingsPage extends ConsumerWidget {
.read(settingsProvider.notifier)
.setSeparateSingles(value),
),
if (settings.separateSingles)
SettingsItem(
icon: Icons.folder_outlined,
title: 'Album Folder Structure',
subtitle: settings.albumFolderStructure == 'album_only'
? 'Albums/Album Name/'
: 'Albums/Artist/Album Name/',
onTap: () => _showAlbumFolderStructurePicker(
context,
ref,
settings.albumFolderStructure,
),
),
if (!settings.separateSingles)
SettingsItem(
icon: Icons.create_new_folder_outlined,
@ -215,7 +230,41 @@ class DownloadSettingsPage extends ConsumerWidget {
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
);
),
);
}
void _showAlbumFolderStructurePicker(BuildContext context, WidgetRef ref, String current) {
showModalBottomSheet(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.folder_outlined),
title: const Text('Artist / Album'),
subtitle: const Text('Albums/Artist Name/Album Name/'),
trailing: current == 'artist_album' ? const Icon(Icons.check) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.album_outlined),
title: const Text('Album Only'),
subtitle: const Text('Albums/Album Name/'),
trailing: current == 'album_only' ? const Icon(Icons.check) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAlbumFolderStructure('album_only');
Navigator.pop(context);
},
),
],
),
),
);
}
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {

View file

@ -56,11 +56,13 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
final topPadding = MediaQuery.of(context).padding.top;
final hasError = extension.status == 'error';
return Scaffold(
body: CustomScrollView(
slivers: [
// App Bar
SliverAppBar(
return PopScope(
canPop: true, // Always allow back gesture
child: Scaffold(
body: CustomScrollView(
slivers: [
// App Bar
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
@ -348,6 +350,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
),
);
}

View file

@ -45,9 +45,11 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
body: CustomScrollView(
slivers: [
return PopScope(
canPop: true, // Always allow back gesture
child: Scaffold(
body: CustomScrollView(
slivers: [
// App Bar
SliverAppBar(
expandedHeight: 120 + topPadding,
@ -248,6 +250,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
),
],
),
),
);
}

View file

@ -124,12 +124,14 @@ class _LogScreenState extends State<LogScreen> {
final topPadding = MediaQuery.of(context).padding.top;
final logs = _filteredLogs;
return Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
// Collapsing App Bar with back button - same as other settings pages
SliverAppBar(
return PopScope(
canPop: true, // Always allow back gesture
child: Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
// Collapsing App Bar with back button - same as other settings pages
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
@ -378,7 +380,8 @@ class _LogScreenState extends State<LogScreen> {
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
);
),
);
}
}

View file

@ -17,11 +17,13 @@ class OptionsSettingsPage extends ConsumerWidget {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
return Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
return PopScope(
canPop: true, // Always allow back gesture
child: Scaffold(
body: CustomScrollView(
slivers: [
// Collapsing App Bar with back button
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
@ -271,7 +273,8 @@ class OptionsSettingsPage extends ConsumerWidget {
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
);
),
);
}
void _showClearHistoryDialog(

View file

@ -116,6 +116,27 @@ class SettingsTab extends ConsumerWidget {
}
void _navigateTo(BuildContext context, Widget page) {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
Navigator.of(context).push(
// Use PageRouteBuilder for better predictive back gesture support
// MaterialPageRoute can cause freeze on some devices with gesture navigation
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
// Use slide transition similar to MaterialPageRoute
const begin = Offset(1.0, 0.0);
const end = Offset.zero;
const curve = Curves.easeInOut;
var tween = Tween(begin: begin, end: end).chain(
CurveTween(curve: curve),
);
return SlideTransition(
position: animation.drive(tween),
child: child,
);
},
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
),
);
}
}

View file

@ -26,6 +26,10 @@ class _StoreTabState extends ConsumerState<StoreTab> {
_isInitialized = true;
final cacheDir = await getApplicationCacheDirectory();
// Check if widget is still mounted after async operation
if (!mounted) return;
await ref.read(storeProvider.notifier).initialize(cacheDir.path);
}