From 7a17de49b2bc6e4c5a58b8baf3a5f9090f6977c9 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 21 Jan 2026 09:16:11 +0700 Subject: [PATCH] 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 --- go_backend/exports.go | 60 +++------- lib/constants/app_info.dart | 4 +- lib/providers/explore_provider.dart | 36 +++--- lib/screens/home_tab.dart | 173 +++++++++++++--------------- pubspec.yaml | 2 +- pubspec_ios.yaml | 2 +- 6 files changed, 124 insertions(+), 153 deletions(-) diff --git a/go_backend/exports.go b/go_backend/exports.go index 6c718a52..b485ca90 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -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) } diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 66e8a243..d300dca4 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -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'; diff --git a/lib/providers/explore_provider.dart b/lib/providers/explore_provider.dart index 14a238cb..63cf137c 100644 --- a/lib/providers/explore_provider.dart +++ b/lib/providers/explore_provider.dart @@ -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 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 { _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 { 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 { 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 { } 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( diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index d1006315..3f57539f 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -34,6 +34,8 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient bool _isTyping = false; final FocusNode _searchFocusNode = FocusNode(); String? _lastSearchQuery; + late final ProviderSubscription _trackStateSub; + late final ProviderSubscription _extensionInitSub; /// Debounce timer for live search (extension-only feature) Timer? _liveSearchDebounce; @@ -58,16 +60,32 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient super.initState(); _urlController.addListener(_onSearchChanged); _searchFocusNode.addListener(_onSearchFocusChanged); + + _trackStateSub = ref.listenManual(trackProvider, (previous, next) { + _onTrackStateChanged(previous, next); + if (previous != null && previous.isLoading && !next.isLoading && next.error == null) { + _navigateToDetailIfNeeded(); + } + }); + + _extensionInitSub = ref.listenManual( + 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 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 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 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 with AutomaticKeepAliveClient }, ); } else { - // Use default settings without quality picker final confirmed = await showDialog( context: this.context, builder: (dialogCtx) => AlertDialog( @@ -427,32 +441,12 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient Widget build(BuildContext context) { super.build(context); - ref.listen(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 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 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 with AutomaticKeepAliveClient } List _buildExploreSections(ExploreState exploreState, ColorScheme colorScheme) { - final slivers = []; + 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 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 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 with AutomaticKeepAliveClient coverUrl: item.coverUrl, ), )); + return; case 'playlist': Navigator.push(context, MaterialPageRoute( builder: (context) => ExtensionPlaylistScreen( @@ -922,6 +927,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient coverUrl: item.coverUrl, ), )); + return; case 'artist': Navigator.push(context, MaterialPageRoute( builder: (context) => ExtensionArtistScreen( @@ -931,10 +937,12 @@ class _HomeTabState extends ConsumerState 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 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 with AutomaticKeepAliveClient borderRadius: BorderRadius.circular(2), ), ), - // Track info Padding( padding: const EdgeInsets.all(16), child: Row( @@ -1013,7 +1019,6 @@ class _HomeTabState extends ConsumerState 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 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 with AutomaticKeepAliveClient Future _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 with AutomaticKeepAliveClient List historyItems, ColorScheme colorScheme, ) { - // Group download history by album final albumGroups = >{}; for (final h in historyItems) { final artistForKey = (h.albumArtist != null && h.albumArtist!.isNotEmpty) @@ -1161,7 +1162,6 @@ class _HomeTabState extends ConsumerState 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( 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, diff --git a/pubspec.yaml b/pubspec.yaml index 18ea5cab..ea2ae88e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index dea7148b..2ace0a56 100644 --- a/pubspec_ios.yaml +++ b/pubspec_ios.yaml @@ -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