fix: add duration_ms to home feed items and bump version to 3.2.0

- Add duration_ms field to ExploreItem model
- Parse duration_ms from spotify-web and ytmusic home feed responses
- Update _downloadExploreTrack to use item.durationMs
- Fixes track duration showing 0:00 in metadata screen after download
- Bump version to 3.2.0+63
This commit is contained in:
zarzet
2026-01-21 09:16:11 +07:00
parent 79180dd918
commit 7a17de49b2
6 changed files with 124 additions and 153 deletions
+14 -46
View File
@@ -2083,8 +2083,7 @@ func ClearStoreCacheJSON() error {
return nil
}
// GetExtensionHomeFeedJSON calls getHomeFeed on any extension that supports it
func GetExtensionHomeFeedJSON(extensionID string) (string, error) {
func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Duration) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil {
@@ -2097,22 +2096,22 @@ func GetExtensionHomeFeedJSON(extensionID string) (string, error) {
provider := NewExtensionProviderWrapper(ext)
script := `
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.getHomeFeed === 'function') {
return extension.getHomeFeed();
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
return extension.%s();
}
return null;
})()
`
`, functionName, functionName)
result, err := RunWithTimeoutAndRecover(provider.vm, script, 60*time.Second)
result, err := RunWithTimeoutAndRecover(provider.vm, script, timeout)
if err != nil {
return "", fmt.Errorf("getHomeFeed failed: %w", err)
return "", fmt.Errorf("%s failed: %w", functionName, err)
}
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return "", fmt.Errorf("getHomeFeed returned null")
return "", fmt.Errorf("%s returned null", functionName)
}
exported := result.Export()
@@ -2124,43 +2123,12 @@ func GetExtensionHomeFeedJSON(extensionID string) (string, error) {
return string(jsonBytes), nil
}
// GetExtensionHomeFeedJSON calls getHomeFeed on any extension that supports it
func GetExtensionHomeFeedJSON(extensionID string) (string, error) {
return callExtensionFunctionJSON(extensionID, "getHomeFeed", 60*time.Second)
}
// GetExtensionBrowseCategoriesJSON calls getBrowseCategories on any extension that supports it
func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil {
return "", err
}
if !ext.Enabled {
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
}
provider := NewExtensionProviderWrapper(ext)
script := `
(function() {
if (typeof extension !== 'undefined' && typeof extension.getBrowseCategories === 'function') {
return extension.getBrowseCategories();
}
return null;
})()
`
result, err := RunWithTimeoutAndRecover(provider.vm, script, 30*time.Second)
if err != nil {
return "", fmt.Errorf("getBrowseCategories failed: %w", err)
}
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return "", fmt.Errorf("getBrowseCategories returned null")
}
exported := result.Export()
jsonBytes, err := json.Marshal(exported)
if err != nil {
return "", fmt.Errorf("failed to marshal result: %w", err)
}
return string(jsonBytes), nil
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
}
+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.1.3';
static const String buildNumber = '62';
static const String version = '3.2.0';
static const String buildNumber = '63';
static const String fullVersion = '$version+$buildNumber';
+23 -13
View File
@@ -17,6 +17,7 @@ class ExploreItem {
final String? providerId;
final String? albumId;
final String? albumName;
final int durationMs;
const ExploreItem({
required this.id,
@@ -29,6 +30,7 @@ class ExploreItem {
this.providerId,
this.albumId,
this.albumName,
this.durationMs = 0,
});
factory ExploreItem.fromJson(Map<String, dynamic> json) {
@@ -43,6 +45,7 @@ class ExploreItem {
providerId: json['provider_id'] as String?,
albumId: json['album_id'] as String?,
albumName: json['album_name'] as String?,
durationMs: json['duration_ms'] as int? ?? 0,
);
}
}
@@ -125,6 +128,11 @@ class ExploreNotifier extends Notifier<ExploreState> {
_log.d('Using cached home feed');
return;
}
if (state.isLoading) {
_log.d('Home feed fetch already in progress');
return;
}
state = state.copyWith(isLoading: true, error: null);
@@ -133,12 +141,21 @@ class ExploreNotifier extends Notifier<ExploreState> {
final extState = ref.read(extensionProvider);
_log.d('Extensions count: ${extState.extensions.length}');
// Look for extensions with homeFeed capability (prefer spotify-web, then ytmusic)
final homeFeedExtensions = extState.extensions.where(
(e) => e.enabled && e.hasHomeFeed,
).toList();
// Look for extensions with homeFeed capability (prefer spotify-web)
Extension? targetExt;
for (final extension in extState.extensions) {
if (!extension.enabled || !extension.hasHomeFeed) {
continue;
}
if (targetExt == null || extension.id == 'spotify-web') {
targetExt = extension;
if (extension.id == 'spotify-web') {
break;
}
}
}
if (homeFeedExtensions.isEmpty) {
if (targetExt == null) {
_log.w('No extension with homeFeed capability found');
state = state.copyWith(
isLoading: false,
@@ -147,16 +164,8 @@ class ExploreNotifier extends Notifier<ExploreState> {
return;
}
// Prefer spotify-web if available, otherwise use first available
var targetExt = homeFeedExtensions.firstWhere(
(e) => e.id == 'spotify-web',
orElse: () => homeFeedExtensions.first,
);
_log.i('Fetching home feed from ${targetExt.id}...');
final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id);
_log.d('getExtensionHomeFeed result: $result');
if (result == null) {
state = state.copyWith(
@@ -167,6 +176,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
}
final success = result['success'] as bool? ?? false;
_log.d('getExtensionHomeFeed success=$success');
if (!success) {
final error = result['error'] as String? ?? 'Unknown error';
state = state.copyWith(
+83 -90
View File
@@ -34,6 +34,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
bool _isTyping = false;
final FocusNode _searchFocusNode = FocusNode();
String? _lastSearchQuery;
late final ProviderSubscription<TrackState> _trackStateSub;
late final ProviderSubscription<bool> _extensionInitSub;
/// Debounce timer for live search (extension-only feature)
Timer? _liveSearchDebounce;
@@ -58,16 +60,32 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
super.initState();
_urlController.addListener(_onSearchChanged);
_searchFocusNode.addListener(_onSearchFocusChanged);
_trackStateSub = ref.listenManual<TrackState>(trackProvider, (previous, next) {
_onTrackStateChanged(previous, next);
if (previous != null && previous.isLoading && !next.isLoading && next.error == null) {
_navigateToDetailIfNeeded();
}
});
_extensionInitSub = ref.listenManual<bool>(
extensionProvider.select((s) => s.isInitialized),
(previous, next) {
if (next == true && previous != true) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _fetchExploreIfNeeded();
});
}
},
);
}
void _fetchExploreIfNeeded() {
final extState = ref.read(extensionProvider);
final exploreState = ref.read(exploreProvider);
// Check if any extension with homeFeed capability is enabled
final hasHomeFeedExtension = extState.extensions.any(
(e) => e.enabled && e.hasHomeFeed,
);
// Fetch if any homeFeed extension is enabled and we don't have content yet
if (hasHomeFeedExtension && !exploreState.hasContent && !exploreState.isLoading) {
ref.read(exploreProvider.notifier).fetchHomeFeed();
}
@@ -76,6 +94,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
@override
void dispose() {
_liveSearchDebounce?.cancel();
_trackStateSub.close();
_extensionInitSub.close();
_urlController.removeListener(_onSearchChanged);
_searchFocusNode.removeListener(_onSearchFocusChanged);
_urlController.dispose();
@@ -123,14 +143,10 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
} else if (text.isEmpty && _isTyping) {
setState(() => _isTyping = false);
_liveSearchDebounce?.cancel();
// Don't clear provider here - it causes focus issues
// Provider will be cleared when user explicitly clears or navigates away
return;
}
// Live search - only for extensions
if (_isLiveSearchEnabled() && text.length >= _minLiveSearchChars) {
// Skip if it's a URL (let user press enter for URLs)
if (text.startsWith('http') || text.startsWith('spotify:')) return;
_liveSearchDebounce?.cancel();
@@ -156,7 +172,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
} finally {
_isLiveSearchInProgress = false;
// Check if there's a pending query that was queued while we were searching
final pending = _pendingLiveSearchQuery;
_pendingLiveSearchQuery = null;
@@ -386,7 +401,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
},
);
} else {
// Use default settings without quality picker
final confirmed = await showDialog<bool>(
context: this.context,
builder: (dialogCtx) => AlertDialog(
@@ -427,32 +441,12 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
Widget build(BuildContext context) {
super.build(context);
ref.listen<TrackState>(trackProvider, (previous, next) {
_onTrackStateChanged(previous, next);
if (previous != null && previous.isLoading && !next.isLoading && next.error == null) {
_navigateToDetailIfNeeded();
}
});
// Listen for extension state changes to trigger explore fetch
ref.listen(extensionProvider.select((s) => s.isInitialized), (previous, next) {
if (next == true && previous != true) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _fetchExploreIfNeeded();
});
}
});
final tracks = ref.watch(trackProvider.select((s) => s.tracks));
final searchArtists = ref.watch(trackProvider.select((s) => s.searchArtists));
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
final error = ref.watch(trackProvider.select((s) => s.error));
final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore));
ref.watch(extensionProvider.select((s) => s.isInitialized));
ref.watch(extensionProvider.select((s) => s.extensions));
// Explore state
final exploreState = ref.watch(exploreProvider);
final hasHomeFeedExtension = ref.watch(extensionProvider.select((s) =>
s.extensions.any((e) => e.enabled && e.hasHomeFeed)
@@ -462,15 +456,15 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
final hasActualResults = tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty);
final isShowingRecentAccess = ref.watch(trackProvider.select((s) => s.isShowingRecentAccess));
final hasResults = isShowingRecentAccess || hasActualResults || isLoading;
final screenHeight = MediaQuery.of(context).size.height;
final topPadding = MediaQuery.of(context).padding.top;
final mediaQuery = MediaQuery.of(context);
final screenHeight = mediaQuery.size.height;
final topPadding = mediaQuery.padding.top;
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
final recentAccessItems = ref.watch(recentAccessProvider.select((s) => s.items));
final hasRecentItems = recentAccessItems.isNotEmpty || historyItems.isNotEmpty;
final showRecentAccess = isShowingRecentAccess && hasRecentItems && !hasActualResults && !isLoading;
// Show explore only when no search results and not showing recent access
final showExplore = !hasActualResults && !isLoading && !showRecentAccess && hasHomeFeedExtension && exploreState.hasContent;
if (hasActualResults && isShowingRecentAccess) {
@@ -619,11 +613,9 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
),
),
// Explore sections (Spotify Home Feed)
if (showExplore)
..._buildExploreSections(exploreState, colorScheme),
// Loading indicator for explore
if (hasHomeFeedExtension && !hasActualResults && !isLoading && exploreState.isLoading)
const SliverToBoxAdapter(
child: Padding(
@@ -720,45 +712,44 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
}
List<Widget> _buildExploreSections(ExploreState exploreState, ColorScheme colorScheme) {
final slivers = <Widget>[];
final greeting = exploreState.greeting;
final hasGreeting = greeting != null && greeting.isNotEmpty;
final sections = exploreState.sections;
final sectionOffset = hasGreeting ? 1 : 0;
final totalCount = sections.length + sectionOffset + 1; // + bottom padding
// Greeting (pull-to-refresh handles refresh)
if (exploreState.greeting != null && exploreState.greeting!.isNotEmpty) {
slivers.add(
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
exploreState.greeting!,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
return [
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (hasGreeting && index == 0) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
greeting!,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
);
}
final sectionIndex = index - sectionOffset;
if (sectionIndex < sections.length) {
return _buildExploreSection(sections[sectionIndex], colorScheme);
}
// Bottom padding
return const SizedBox(height: 16);
},
childCount: totalCount,
),
);
}
// Build each section
for (final section in exploreState.sections) {
slivers.add(
SliverToBoxAdapter(
child: _buildExploreSection(section, colorScheme),
),
);
}
// Add some bottom padding
slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 16)));
return slivers;
),
];
}
Widget _buildExploreSection(ExploreSection section, ColorScheme colorScheme) {
// Check if this is a YT Music "Quick picks" style section (vertical list)
final isYTMusicQuickPicks = section.items.isNotEmpty &&
section.items.first.providerId == 'ytmusic-spotiflac' &&
section.items.every((item) => item.type == 'track');
final isYTMusicQuickPicks = _isYTMusicQuickPicksSection(section);
if (isYTMusicQuickPicks) {
return _buildYTMusicQuickPicksSection(section, colorScheme);
@@ -792,6 +783,19 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
);
}
bool _isYTMusicQuickPicksSection(ExploreSection section) {
if (section.items.isEmpty) return false;
if (section.items.first.providerId != 'ytmusic-spotiflac') return false;
for (final item in section.items) {
if (item.type != 'track') {
return false;
}
}
return true;
}
/// Build YT Music "Quick picks" style swipeable pages section
Widget _buildYTMusicQuickPicksSection(ExploreSection section, ColorScheme colorScheme) {
const itemsPerPage = 5;
@@ -902,8 +906,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
switch (item.type) {
case 'track':
// Show bottom sheet with track info and download option
_showTrackBottomSheet(item);
return;
case 'album':
Navigator.push(context, MaterialPageRoute(
builder: (context) => ExtensionAlbumScreen(
@@ -913,6 +917,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
coverUrl: item.coverUrl,
),
));
return;
case 'playlist':
Navigator.push(context, MaterialPageRoute(
builder: (context) => ExtensionPlaylistScreen(
@@ -922,6 +927,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
coverUrl: item.coverUrl,
),
));
return;
case 'artist':
Navigator.push(context, MaterialPageRoute(
builder: (context) => ExtensionArtistScreen(
@@ -931,10 +937,12 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
coverUrl: item.coverUrl,
),
));
return;
default:
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${item.type}: ${item.name}')),
);
return;
}
}
@@ -951,7 +959,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle bar
Container(
margin: const EdgeInsets.only(top: 12),
width: 40,
@@ -961,7 +968,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
borderRadius: BorderRadius.circular(2),
),
),
// Track info
Padding(
padding: const EdgeInsets.all(16),
child: Row(
@@ -1013,7 +1019,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
),
),
const Divider(height: 1),
// Actions
ListTile(
leading: Icon(Icons.download, color: colorScheme.primary),
title: Text(context.l10n.downloadTitle),
@@ -1027,7 +1032,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
title: const Text('Go to Album'),
onTap: () {
Navigator.pop(context);
// Navigate to album - we'll use the track ID to search
_navigateToTrackAlbum(item);
},
),
@@ -1041,17 +1045,15 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
Future<void> _downloadExploreTrack(ExploreItem item) async {
final settings = ref.read(settingsProvider);
// Create a Track object from ExploreItem
// Pass spotify ID as ISRC so enrichment can look it up via SongLink/Deezer
final track = Track(
id: item.id,
name: item.name,
artistName: item.artists,
albumName: item.albumName ?? '',
duration: 0,
duration: item.durationMs ~/ 1000,
trackNumber: 1,
discNumber: 1,
isrc: item.id, // Pass Spotify ID - enrichment will detect and lookup real ISRC
isrc: item.id,
releaseDate: null,
coverUrl: item.coverUrl,
source: item.providerId ?? 'spotify-web',
@@ -1100,7 +1102,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
List<DownloadHistoryItem> historyItems,
ColorScheme colorScheme,
) {
// Group download history by album
final albumGroups = <String, List<DownloadHistoryItem>>{};
for (final h in historyItems) {
final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty)
@@ -1161,7 +1162,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
return true;
}).take(10).toList();
// Check if there are hidden downloads
final hasHiddenDownloads = hiddenIds.isNotEmpty;
return Padding(
@@ -1959,7 +1959,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
currentExt = searchProviders.where((e) => e.id == currentProvider).firstOrNull;
}
// Determine display icon
IconData displayIcon = Icons.search;
String? iconPath;
if (currentExt != null) {
@@ -2037,7 +2036,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
),
),
if (searchProviders.isNotEmpty) const PopupMenuDivider(),
// Extension providers
...searchProviders.map((ext) => PopupMenuItem<String>(
value: ext.id,
child: Row(
@@ -2900,7 +2898,6 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> {
),
),
),
// Swipeable pages of tracks
SizedBox(
height: widget.itemsPerPage * 64.0,
child: PageView.builder(
@@ -2920,7 +2917,6 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> {
},
),
),
// Page indicator dots
if (widget.totalPages > 1)
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
@@ -2950,12 +2946,11 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> {
Widget _buildQuickPickItem(ExploreItem item) {
return InkWell(
onTap: () => widget.onItemTap(item),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
// Album art thumbnail
ClipRRect(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
? CachedNetworkImage(
@@ -2989,7 +2984,6 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> {
),
),
const SizedBox(width: 12),
// Title and artist
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -3015,7 +3009,6 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> {
],
),
),
// Menu button
IconButton(
icon: Icon(
Icons.more_vert,
+1 -1
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.1.3+62
version: 3.2.0+63
environment:
sdk: ^3.10.0
+1 -1
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.1.3+62
version: 3.2.0+63
environment:
sdk: ^3.10.0