feat: expose audio duration in metadata API and fix home empty-state race

- Add Duration field to AudioQuality for FLAC (streaminfo) and M4A (mvhd atom)
- Expose duration via ReadFileMetadata and extension runtime Go-backend API
- Pass duration_ms to extension CheckAvailability for better track matching
- Fix home tab showing empty state before extensions finish initializing by
  keeping the search bar visible with a loading indicator until ready
- Refactor hasSearchProvider helper to account for built-in providers
- Refine homeEmptyTitle/Subtitle copy (EN + ID translations)
- Bump version to 4.5.0+127
This commit is contained in:
zarzet
2026-04-24 04:38:41 +07:00
parent bb7c86c29e
commit cd2c2a9854
23 changed files with 277 additions and 110 deletions
+2 -2
View File
@@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart';
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '4.3.1';
static const String buildNumber = '126';
static const String version = '4.5.0';
static const String buildNumber = '127';
static const String fullVersion = '$version+$buildNumber';
/// Shows "Internal" in debug builds, actual version in release.
+2 -2
View File
@@ -169,13 +169,13 @@ abstract class AppLocalizations {
/// Title shown on home when no providers are available yet
///
/// In en, this message translates to:
/// **'Home is empty'**
/// **'No search providers yet'**
String get homeEmptyTitle;
/// Subtitle shown on home when no providers are available yet
///
/// In en, this message translates to:
/// **'Install your first extension to unlock search and browsing.'**
/// **'Install an extension to continue.'**
String get homeEmptySubtitle;
/// Info text about supported URL types
+2 -3
View File
@@ -30,11 +30,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get homeSubtitle => 'Spotify-Link einfügen oder nach Namen suchen';
@override
String get homeEmptyTitle => 'Home is empty';
String get homeEmptyTitle => 'No search providers yet';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
String get homeEmptySubtitle => 'Install an extension to continue.';
@override
String get homeSupports =>
+2 -3
View File
@@ -30,11 +30,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get homeSubtitle => 'Paste a supported URL or search by name';
@override
String get homeEmptyTitle => 'Home is empty';
String get homeEmptyTitle => 'No search providers yet';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
String get homeEmptySubtitle => 'Install an extension to continue.';
@override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
+2 -3
View File
@@ -30,11 +30,10 @@ class AppLocalizationsEs extends AppLocalizations {
String get homeSubtitle => 'Paste a Spotify link or search by name';
@override
String get homeEmptyTitle => 'Home is empty';
String get homeEmptyTitle => 'No search providers yet';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
String get homeEmptySubtitle => 'Install an extension to continue.';
@override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
+2 -3
View File
@@ -30,11 +30,10 @@ class AppLocalizationsFr extends AppLocalizations {
String get homeSubtitle => 'Coller un lien Spotify ou rechercher par nom';
@override
String get homeEmptyTitle => 'Home is empty';
String get homeEmptyTitle => 'No search providers yet';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
String get homeEmptySubtitle => 'Install an extension to continue.';
@override
String get homeSupports => 'Supports: Piste, Album, Playlist, Artiste URLs';
+2 -3
View File
@@ -30,11 +30,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get homeSubtitle => 'Paste a Spotify link or search by name';
@override
String get homeEmptyTitle => 'Home is empty';
String get homeEmptyTitle => 'No search providers yet';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
String get homeEmptySubtitle => 'Install an extension to continue.';
@override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
+2 -3
View File
@@ -31,11 +31,10 @@ class AppLocalizationsId extends AppLocalizations {
'Tempel URL yang didukung atau cari berdasarkan nama';
@override
String get homeEmptyTitle => 'Home is empty';
String get homeEmptyTitle => 'Belum ada provider pencarian';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
String get homeEmptySubtitle => 'Pasang ekstensi untuk melanjutkan.';
@override
String get homeSupports => 'Mendukung: URL Track, Album, Playlist, Artis';
+2 -3
View File
@@ -30,11 +30,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get homeSubtitle => 'Spotify のリンクを貼り付けるか、名前で検索します';
@override
String get homeEmptyTitle => 'Home is empty';
String get homeEmptyTitle => 'No search providers yet';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
String get homeEmptySubtitle => 'Install an extension to continue.';
@override
String get homeSupports => 'サポート: トラック、アルバム、プレイリスト、アーティスト、URL';
+2 -3
View File
@@ -30,11 +30,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get homeSubtitle => 'Spotify URL을 붙여 넣거나 검색';
@override
String get homeEmptyTitle => 'Home is empty';
String get homeEmptyTitle => 'No search providers yet';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
String get homeEmptySubtitle => 'Install an extension to continue.';
@override
String get homeSupports => '지원 항목: 트랙, 앨범, 플레이리스트, 아티스트 URLs';
+2 -3
View File
@@ -30,11 +30,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get homeSubtitle => 'Paste a Spotify link or search by name';
@override
String get homeEmptyTitle => 'Home is empty';
String get homeEmptyTitle => 'No search providers yet';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
String get homeEmptySubtitle => 'Install an extension to continue.';
@override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
+2 -3
View File
@@ -30,11 +30,10 @@ class AppLocalizationsPt extends AppLocalizations {
String get homeSubtitle => 'Paste a Spotify link or search by name';
@override
String get homeEmptyTitle => 'Home is empty';
String get homeEmptyTitle => 'No search providers yet';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
String get homeEmptySubtitle => 'Install an extension to continue.';
@override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
+2 -3
View File
@@ -30,11 +30,10 @@ class AppLocalizationsRu extends AppLocalizations {
String get homeSubtitle => 'Вставьте ссылку Spotify или ищите по названию';
@override
String get homeEmptyTitle => 'Home is empty';
String get homeEmptyTitle => 'No search providers yet';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
String get homeEmptySubtitle => 'Install an extension to continue.';
@override
String get homeSupports =>
+2 -3
View File
@@ -31,11 +31,10 @@ class AppLocalizationsTr extends AppLocalizations {
'Bir Spotify bağlantısı yapıştırın veya şarkı arayın';
@override
String get homeEmptyTitle => 'Home is empty';
String get homeEmptyTitle => 'No search providers yet';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
String get homeEmptySubtitle => 'Install an extension to continue.';
@override
String get homeSupports =>
+2 -3
View File
@@ -30,11 +30,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get homeSubtitle => 'Paste a Spotify link or search by name';
@override
String get homeEmptyTitle => 'Home is empty';
String get homeEmptyTitle => 'No search providers yet';
@override
String get homeEmptySubtitle =>
'Install your first extension to unlock search and browsing.';
String get homeEmptySubtitle => 'Install an extension to continue.';
@override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
+2 -2
View File
@@ -29,11 +29,11 @@
"@homeSubtitle": {
"description": "Subtitle shown below search box"
},
"homeEmptyTitle": "Home is empty",
"homeEmptyTitle": "No search providers yet",
"@homeEmptyTitle": {
"description": "Title shown on home when no providers are available yet"
},
"homeEmptySubtitle": "Install your first extension to unlock search and browsing.",
"homeEmptySubtitle": "Install an extension to continue.",
"@homeEmptySubtitle": {
"description": "Subtitle shown on home when no providers are available yet"
},
+8
View File
@@ -29,6 +29,14 @@
"@homeSubtitle": {
"description": "Subtitle shown below search box"
},
"homeEmptyTitle": "Belum ada provider pencarian",
"@homeEmptyTitle": {
"description": "Title shown on home when no providers are available yet"
},
"homeEmptySubtitle": "Pasang ekstensi untuk melanjutkan.",
"@homeEmptySubtitle": {
"description": "Subtitle shown on home when no providers are available yet"
},
"homeSupports": "Mendukung: URL Track, Album, Playlist, Artis",
"@homeSupports": {
"description": "Info text about supported URL types"
+179 -57
View File
@@ -509,6 +509,27 @@ class _HomeTabState extends ConsumerState<HomeTab>
defaultBuiltInSearchProviderId;
}
bool _hasSearchProvider(
String? explicitSearchProvider,
List<Extension> extensions,
List<BuiltInProviderSpec> builtInProviders,
) {
final explicit = explicitSearchProvider?.trim();
if (explicit != null && explicit.isNotEmpty) {
if (builtInProviders.any((p) => p.supportsSearch && p.id == explicit)) {
return true;
}
if (extensions.any(
(ext) => ext.enabled && ext.hasCustomSearch && ext.id == explicit,
)) {
return true;
}
}
return extensions.any((ext) => ext.enabled && ext.hasCustomSearch) ||
builtInProviders.any((provider) => provider.supportsSearch);
}
String? _sanitizeSearchFilterForProvider(
String? filter,
String? currentSearchProvider,
@@ -707,6 +728,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
bool _isLiveSearchEnabled() {
final settings = ref.read(settingsProvider);
final extState = ref.read(extensionProvider);
if (!extState.isInitialized && extState.error == null) return true;
final searchProvider = _resolveSearchProvider(
settings.searchProvider,
extState.extensions,
@@ -774,8 +797,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
Future<void> _performSearch(String query, {String? filterOverride}) async {
var extState = ref.read(extensionProvider);
if (!extState.isInitialized && extState.error == null) {
await ref.read(extensionProvider.notifier).waitForInitialization();
extState = ref.read(extensionProvider);
}
final settings = ref.read(settingsProvider);
final extState = ref.read(extensionProvider);
final searchProvider = _resolveSearchProvider(
settings.searchProvider,
extState.extensions,
@@ -1015,9 +1043,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
return;
}
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, service);
ref.read(downloadQueueProvider.notifier).addToQueue(track, service);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
@@ -1299,6 +1325,15 @@ class _HomeTabState extends ConsumerState<HomeTab>
settingsProvider.select((s) => s.defaultSearchTab),
);
final extensions = ref.watch(extensionProvider.select((s) => s.extensions));
final extensionReadiness = ref.watch(
extensionProvider.select(
(s) => (
isInitialized: s.isInitialized,
error: s.error,
builtInProviders: s.builtInProviders,
),
),
);
final hasExploreContent = ref.watch(
exploreProvider.select((s) => s.sections.isNotEmpty),
@@ -1336,9 +1371,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
recentModeRequested &&
(!hasSearchInput || hasShortSearchInput || !hasActualResults) &&
!isLoading;
final hasSearchProvider =
(_resolveSearchProvider(explicitSearchProvider, extensions) ?? '')
.isNotEmpty;
final isSearchProviderLoading =
!extensionReadiness.isInitialized && extensionReadiness.error == null;
final hasSearchProvider = _hasSearchProvider(
explicitSearchProvider,
extensions,
extensionReadiness.builtInProviders,
);
final showSearchBar = hasSearchProvider || isSearchProviderLoading;
final hasResults =
hasSearchInput || hasActualResults || isLoading || showRecentAccess;
final showExplore =
@@ -1349,6 +1389,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
(hasHomeFeedExtension || hasExploreContent) &&
hasExploreContent;
final showEmptyHomeState =
!isSearchProviderLoading &&
!hasSearchProvider &&
!hasHomeFeedExtension &&
!hasExploreContent &&
@@ -1442,56 +1483,15 @@ class _HomeTabState extends ConsumerState<HomeTab>
curve: Curves.easeOut,
child: (hasResults || showExplore)
? const SizedBox.shrink()
: Column(
children: [
SizedBox(height: screenHeight * 0.06),
Container(
width: 96,
height: 96,
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
),
child: Image.asset(
'assets/images/logo-transparant.png',
color: colorScheme.onPrimary,
fit: BoxFit.contain,
errorBuilder: (_, _, _) => ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Image.asset(
'assets/images/logo.png',
width: 96,
height: 96,
fit: BoxFit.cover,
),
),
),
),
const SizedBox(height: 16),
Text(
showEmptyHomeState
? context.l10n.homeEmptyTitle
: 'SpotiFLAC Mobile',
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
showEmptyHomeState
? context.l10n.homeEmptySubtitle
: context.l10n.homeSubtitle,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
: _buildHomeIntro(
colorScheme: colorScheme,
screenHeight: screenHeight,
showEmptyHomeState: showEmptyHomeState,
),
),
),
if (hasSearchProvider)
if (showSearchBar)
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(
@@ -1690,6 +1690,98 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
}
Widget _buildHomeIntro({
required ColorScheme colorScheme,
required double screenHeight,
required bool showEmptyHomeState,
}) {
if (showEmptyHomeState) {
final emptyHeight = (screenHeight - 220).clamp(280.0, 520.0).toDouble();
return SizedBox(
height: emptyHeight,
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.extension_outlined,
size: 56,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
context.l10n.homeEmptyTitle,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
context.l10n.homeEmptySubtitle,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
SizedBox(height: screenHeight * 0.06),
Container(
width: 96,
height: 96,
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
),
child: Image.asset(
'assets/images/logo-transparant.png',
color: colorScheme.onPrimary,
fit: BoxFit.contain,
errorBuilder: (_, _, _) => ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Image.asset(
'assets/images/logo.png',
width: 96,
height: 96,
fit: BoxFit.cover,
),
),
),
),
const SizedBox(height: 16),
Text(
'SpotiFLAC Mobile',
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
context.l10n.homeSubtitle,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
);
}
void _onEmbeddedCoverChanged() {
if (!mounted || _embeddedCoverRefreshScheduled) return;
_embeddedCoverRefreshScheduled = true;
@@ -2176,9 +2268,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
return;
}
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, service);
ref.read(downloadQueueProvider.notifier).addToQueue(track, service);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
);
@@ -3636,13 +3726,45 @@ class _SearchProviderDropdown extends ConsumerWidget {
final rawCurrentProvider = ref.watch(
settingsProvider.select((s) => s.searchProvider),
);
final extensions = ref.watch(extensionProvider.select((s) => s.extensions));
final extensionState = ref.watch(extensionProvider);
final extensions = extensionState.extensions;
final colorScheme = Theme.of(context).colorScheme;
final searchProviders = extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
.toList();
final builtInProviders = builtInSearchProviderSpecs;
final hasAnyProvider =
searchProviders.isNotEmpty || builtInProviders.isNotEmpty;
final isProviderLoading =
!extensionState.isInitialized && extensionState.error == null;
if (!hasAnyProvider) {
return Padding(
padding: const EdgeInsets.only(left: 12, right: 8),
child: SizedBox(
width: 28,
height: 28,
child: Center(
child: isProviderLoading
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.primary,
),
)
: Icon(
Icons.search_off,
size: 20,
color: colorScheme.onSurfaceVariant,
),
),
),
);
}
final resolvedCurrentProvider =
rawCurrentProvider != null &&
rawCurrentProvider.isNotEmpty &&