diff --git a/lib/core/data/models/report_request.dart b/lib/core/data/models/report_request.dart index 496f4743..79f98413 100644 --- a/lib/core/data/models/report_request.dart +++ b/lib/core/data/models/report_request.dart @@ -1,31 +1,22 @@ class ReportRequest { - final String contentId; + final String resourceId; + final String resourceType; final String reason; - final String details; - final String name; - final String email; - final String url; - final List violations; + final String description; ReportRequest({ - required this.contentId, + required this.resourceId, + this.resourceType = 'track', required this.reason, - required this.details, - required this.name, - required this.email, - required this.url, - required this.violations, + required this.description, }); Map toJson() { return { - "contentId": contentId, + "resource_id": resourceId, + "resource_type": resourceType, "reason": reason, - "details": details, - "name": name, - "email": email, - "url": url, - "violations": violations, + "description": description, }; } } diff --git a/lib/core/data/models/track_dto.dart b/lib/core/data/models/track_dto.dart index c01a7ac3..e33b83d5 100644 --- a/lib/core/data/models/track_dto.dart +++ b/lib/core/data/models/track_dto.dart @@ -21,6 +21,10 @@ class TrackDto { data['artists'] as String? ?? data['user']?['display_name'] as String? ?? '', + artistUsername: + data['username'] as String? ?? + data['artist_username'] as String? ?? + data['user']?['username'] as String?, artistPfp: data['profile_picture'] as String? ?? @@ -101,6 +105,7 @@ class TrackDto { 'user_id': track.userId, 'title': track.title, 'artist': track.artist, + 'artist_username': track.artistUsername, 'artist_pfp': track.artistPfp, 'artist_city': track.artistCity, 'artist_country': track.artistCountry, diff --git a/lib/core/domain/entities/track.dart b/lib/core/domain/entities/track.dart index 806c4324..38196087 100644 --- a/lib/core/domain/entities/track.dart +++ b/lib/core/domain/entities/track.dart @@ -3,6 +3,7 @@ class Track { final String userId; final String title; final String artist; + final String? artistUsername; final String? artistPfp; final String? artistCity; final String? artistCountry; @@ -46,6 +47,7 @@ class Track { required this.userId, required this.title, required this.artist, + this.artistUsername, this.artistPfp, this.artistCity, this.artistCountry, @@ -88,6 +90,7 @@ class Track { String? userId, String? title, String? artist, + String? artistUsername, String? artistPfp, String? artistCity, String? artistCountry, @@ -126,6 +129,7 @@ class Track { userId: userId ?? this.userId, title: title ?? this.title, artist: artist ?? this.artist, + artistUsername: artistUsername ?? this.artistUsername, artistPfp: artistPfp ?? this.artistPfp, artistCity: artistCity ?? this.artistCity, artistCountry: artistCountry ?? this.artistCountry, diff --git a/lib/core/presentation/pages/report_page.dart b/lib/core/presentation/pages/report_page.dart index 7d542217..d85e4eac 100644 --- a/lib/core/presentation/pages/report_page.dart +++ b/lib/core/presentation/pages/report_page.dart @@ -1,14 +1,19 @@ import 'package:flutter/material.dart'; -import 'package:dio/dio.dart'; import '../../theme/app_theme.dart'; import '../../data/models/report_request.dart'; import '../../data/models/report_repository.dart'; +import '../../network/api_client.dart'; class ReportPage extends StatefulWidget { final String reportedContentId; + final String resourceType; - const ReportPage({super.key, required this.reportedContentId}); + const ReportPage({ + super.key, + required this.reportedContentId, + this.resourceType = 'track', + }); @override State createState() => _ReportPageState(); @@ -23,27 +28,27 @@ class _ReportPageState extends State { final _detailsController = TextEditingController(); final _nameController = TextEditingController(); - final _emailController = TextEditingController( - text: 'basseialaa33@gmail.com', - ); + final _emailController = TextEditingController(text: ''); final _urlController = TextEditingController(); + final _resourceTypeController = TextEditingController(); + final _resourceIdController = TextEditingController(); late final ReportRepository _repository; final List _reasons = [ - "It's hate speech", - "It's harassing or abusive content", - "It contains sexual content or nudity", - "It contains graphic violence", - "It promotes self-harm", - "It's infringement of intellectual property", - "It's spam or misleading content", - "It's private or confidential information", - "It's selling illegal goods", - "Something else", - "I just don't like it", + "Copyright", + "Inappropriate content", + "Spam", + "Impersonation", ]; + final Map _reasonMapping = { + "Copyright": "copyright", + "Inappropriate content": "inappropriate", + "Spam": "spam", + "Impersonation": "impersonation", + }; + final Map _violations = { 'Audio': false, 'Title/Description': false, @@ -57,11 +62,19 @@ class _ReportPageState extends State { void initState() { super.initState(); - _repository = ReportRepository(Dio()); + _repository = ReportRepository(apiClient.dio); - // ✅ Auto-generate URL - _urlController.text = - 'https://rythmify.com/tracks/${widget.reportedContentId}'; + _resourceIdController.text = widget.reportedContentId; + _resourceTypeController.text = widget.resourceType; + + // Adjust URL generation based on type + if (widget.resourceType == 'track') { + _urlController.text = + 'https://rythmify.com/tracks/${widget.reportedContentId}'; + } else if (widget.resourceType == 'user') { + _urlController.text = + 'https://rythmify.com/profile/${widget.reportedContentId}'; + } } @override @@ -70,6 +83,8 @@ class _ReportPageState extends State { _nameController.dispose(); _emailController.dispose(); _urlController.dispose(); + _resourceIdController.dispose(); + _resourceTypeController.dispose(); super.dispose(); } @@ -80,19 +95,13 @@ class _ReportPageState extends State { return; } - final selectedViolations = _violations.entries - .where((e) => e.value) - .map((e) => e.key) - .toList(); + final reasonSlug = _reasonMapping[_selectedReason!] ?? 'inappropriate'; final request = ReportRequest( - contentId: widget.reportedContentId, - reason: _selectedReason!, - details: _detailsController.text, - name: _nameController.text, - email: _emailController.text, - url: _urlController.text, - violations: selectedViolations, + resourceId: widget.reportedContentId, + resourceType: widget.resourceType, + reason: reasonSlug, + description: _detailsController.text, ); setState(() => _isLoading = true); @@ -121,6 +130,12 @@ class _ReportPageState extends State { @override Widget build(BuildContext context) { + // Determine which reasons to show based on resource type + final filteredReasons = _reasons.where((r) { + if (widget.resourceType == 'user' && r == 'Copyright') return false; + return true; + }).toList(); + final bool isFormValid = _selectedReason != null && _isConsentChecked && !_isLoading; @@ -144,6 +159,40 @@ class _ReportPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + /// RESOURCE PREVIEW + Text( + 'Report Details', + style: AppTheme.titleMedium.copyWith(color: AppTheme.babyBlue), + ), + const SizedBox(height: 12), + TextFormField( + controller: _resourceTypeController, + readOnly: true, + decoration: InputDecoration( + labelText: 'Resource Type', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + filled: true, + fillColor: Colors.white10, + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _resourceIdController, + readOnly: true, + decoration: InputDecoration( + labelText: 'Resource ID', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + filled: true, + fillColor: Colors.white10, + ), + ), + + const SizedBox(height: 32), + /// TITLE Text( 'Reason for Reporting', @@ -152,9 +201,8 @@ class _ReportPageState extends State { const SizedBox(height: 12), - /// ✅ FIXED RADIO LIST (NO NEGATIVE SPACING) Column( - children: _reasons.map((reason) { + children: filteredReasons.map((reason) { return RadioListTile( title: Text(reason, style: AppTheme.bodyNormal), value: reason, @@ -165,7 +213,7 @@ class _ReportPageState extends State { setState(() => _selectedReason = value); }, contentPadding: EdgeInsets.zero, - dense: true, // ✅ cleaner spacing + dense: true, activeColor: AppTheme.primaryBrand, ); }).toList(), diff --git a/lib/features/comments/presentation/pages/comments_screen.dart b/lib/features/comments/presentation/pages/comments_screen.dart index d4f832ac..f1c6c6a4 100644 --- a/lib/features/comments/presentation/pages/comments_screen.dart +++ b/lib/features/comments/presentation/pages/comments_screen.dart @@ -52,9 +52,12 @@ class _CommentsScreenState extends ConsumerState { }); WidgetsBinding.instance.addPostFrameCallback((_) { - ref - .read(trackCommentsProvider(widget.track.id).notifier) - .setInitialTrack(widget.track); + final notifier = ref.read( + trackCommentsProvider(widget.track.id).notifier, + ); + notifier.setInitialTrack(widget.track); + // Force a fresh fetch from the server every time the screen opens + notifier.fetchComments(refresh: true); }); } diff --git a/lib/features/comments/presentation/widgets/comment_action_bottom_sheet.dart b/lib/features/comments/presentation/widgets/comment_action_bottom_sheet.dart index 806950fc..0327f67a 100644 --- a/lib/features/comments/presentation/widgets/comment_action_bottom_sheet.dart +++ b/lib/features/comments/presentation/widgets/comment_action_bottom_sheet.dart @@ -68,11 +68,12 @@ class CommentActionBottomSheet extends ConsumerWidget { } } - void _reportUser(BuildContext context) { + void _reportComment(BuildContext context) { Navigator.pop(context); Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( - builder: (context) => ReportPage(reportedContentId: comment.id), + builder: (context) => + ReportPage(reportedContentId: comment.userId, resourceType: 'user'), ), ); } @@ -159,8 +160,8 @@ class CommentActionBottomSheet extends ConsumerWidget { _buildActionRow( key: const Key('comment_action_report_inkwell'), icon: Icons.flag_outlined, - label: 'Report user', - onTap: () => _reportUser(context), + label: 'Report comment', + onTap: () => _reportComment(context), ), // Row 6 (Other): Block/Unblock diff --git a/lib/features/library/presentation/pages/downloads_page.dart b/lib/features/library/presentation/pages/downloads_page.dart index 2fd608ba..41795fc3 100644 --- a/lib/features/library/presentation/pages/downloads_page.dart +++ b/lib/features/library/presentation/pages/downloads_page.dart @@ -119,6 +119,40 @@ class _DownloadsPageState extends ConsumerState { ); } + Future showDeleteConfirmation(Track track) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: AppTheme.surface, + title: const Text('Delete Download'), + content: Text( + 'Do you really want to delete "${track.title}" from your downloads? It cannot be restored.', + style: const TextStyle(color: Colors.white70), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text( + 'Cancel', + style: TextStyle(color: Colors.white54), + ), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text( + 'Delete', + style: TextStyle(color: Colors.redAccent), + ), + ), + ], + ), + ); + + if (confirmed == true) { + await ref.read(downloadsProvider.notifier).removeTrack(track.id); + } + } + return CustomScrollView( key: const Key('downloads_scroll_view'), controller: _scrollController, @@ -214,12 +248,22 @@ class _DownloadsPageState extends ConsumerState { sliver: SliverList.builder( itemCount: filtered.length, itemBuilder: (context, index) { + final track = filteredTracks[index]; return TrackCard( - key: Key('downloads_item_${filteredTracks[index].id}_$index'), - track: filteredTracks[index], + key: Key('downloads_item_${track.id}_$index'), + track: track, onTap: () => playAt(index), showOptions: false, showStats: false, + trailing: IconButton( + key: Key('downloads_delete_button_${track.id}'), + icon: const Icon( + Icons.delete_outline_rounded, + color: Colors.white54, + size: 22, + ), + onPressed: () => showDeleteConfirmation(track), + ), ); }, ), diff --git a/lib/features/library/presentation/pages/following_page.dart b/lib/features/library/presentation/pages/following_page.dart index 0578e1fe..0d7a9540 100644 --- a/lib/features/library/presentation/pages/following_page.dart +++ b/lib/features/library/presentation/pages/following_page.dart @@ -131,7 +131,7 @@ class _FollowingPageState extends ConsumerState { ), ), ) - : const SizedBox.shrink(); + : const SizedBox(height: 500); // bottom spacing } return _UserTile(user: state.users[index]); }, @@ -214,7 +214,7 @@ class _UserTile extends ConsumerWidget { ], ), subtitle: Text( - '${_formatCount(user.followersCount)} followers', + '@${user.username}', key: Key('following_item_${user.id}_followers_text'), style: AppTheme.labelSmall, ), @@ -233,15 +233,4 @@ class _UserTile extends ConsumerWidget { ), ); } - - /// Formats a count integer with K/M suffix abbreviations. - /// - /// - Values >= 1,000,000 are shown as `X.XM` - /// - Values >= 1,000 are shown as `X.XK` - /// - Smaller values are shown as-is - String _formatCount(int count) { - if (count >= 1000000) return '${(count / 1000000).toStringAsFixed(1)}M'; - if (count >= 1000) return '${(count / 1000).toStringAsFixed(1)}K'; - return count.toString(); - } } diff --git a/lib/features/library/presentation/pages/insights_page.dart b/lib/features/library/presentation/pages/insights_page.dart index ca00074b..427893ee 100644 --- a/lib/features/library/presentation/pages/insights_page.dart +++ b/lib/features/library/presentation/pages/insights_page.dart @@ -228,8 +228,9 @@ class _SoundCloudDataView extends StatelessWidget { @override Widget build(BuildContext context) { final totalPlays = insights.fold(0, (s, i) => s + i.totalPlays); - final totalListeners = insights.fold(0, (s, i) => s + i.uniqueListeners); final totalLikes = insights.fold(0, (s, i) => s + i.likes); + final totalReposts = insights.fold(0, (s, i) => s + i.reposts); + final totalComments = insights.fold(0, (s, i) => s + i.comments); return ListView( key: const Key('insights_sc_data_list_view'), @@ -258,10 +259,10 @@ class _SoundCloudDataView extends StatelessWidget { ), Expanded( child: _StatItem( - icon: Icons.people_outline, - label: 'Listeners', - value: _fmt(totalListeners), - key: const Key('insights_total_listeners_stat'), + icon: Icons.repeat, + label: 'Reposts', + value: _fmt(totalReposts), + key: const Key('insights_total_reposts_stat'), ), ), Expanded( @@ -272,6 +273,14 @@ class _SoundCloudDataView extends StatelessWidget { key: const Key('insights_total_likes_stat'), ), ), + Expanded( + child: _StatItem( + icon: Icons.comment, + label: 'Comments', + value: _fmt(totalComments), + key: const Key('insights_total_comments_stat'), + ), + ), ], ), ), @@ -454,10 +463,10 @@ class _InsightTrackCard extends StatelessWidget { ), Expanded( child: _MiniStat( - icon: Icons.people_outline, - value: _fmt(insight.uniqueListeners), - label: 'listeners', - key: Key('insights_track_${insight.trackId}_listeners_stat'), + icon: Icons.comment, + value: _fmt(insight.comments), + label: 'comments', + key: Key('insights_track_${insight.trackId}_comments_stat'), ), ), Expanded( diff --git a/lib/features/messaging/presentation/widgets/pop_up_menu_widget.dart b/lib/features/messaging/presentation/widgets/pop_up_menu_widget.dart index c7999fa8..4969dc03 100644 --- a/lib/features/messaging/presentation/widgets/pop_up_menu_widget.dart +++ b/lib/features/messaging/presentation/widgets/pop_up_menu_widget.dart @@ -94,8 +94,10 @@ class PopUpMenuWidget extends ConsumerWidget { Navigator.pop(context); Navigator.of(parentContext, rootNavigator: true).push( MaterialPageRoute( - builder: (_) => - ReportPage(reportedContentId: participantId), + builder: (_) => ReportPage( + reportedContentId: participantId, + resourceType: 'user', + ), ), ); }, diff --git a/lib/features/player/presentation/pages/queue_screen.dart b/lib/features/player/presentation/pages/queue_screen.dart index 3ad3ae03..707ee78a 100644 --- a/lib/features/player/presentation/pages/queue_screen.dart +++ b/lib/features/player/presentation/pages/queue_screen.dart @@ -158,19 +158,17 @@ class _QueueScreenState extends ConsumerState { }, itemBuilder: (context, index) { final item = manualUpcoming[index]; - return ReorderableDelayedDragStartListener( + return Material( key: ValueKey(item.queueItemId), - index: index, - child: Material( - color: Colors.transparent, - child: _QueueTile( - item: item, - onTap: () { - ref - .read(queueStateProvider.notifier) - .playFromQueue(index); - }, - ), + color: Colors.transparent, + child: _QueueTile( + item: item, + index: index, + onTap: () { + ref + .read(queueStateProvider.notifier) + .playFromQueue(index); + }, ), ); }, @@ -234,6 +232,7 @@ class _QueueTile extends StatelessWidget { final bool isHistory; final bool showDragHandle; final VoidCallback onTap; + final int? index; const _QueueTile({ required this.item, @@ -241,6 +240,7 @@ class _QueueTile extends StatelessWidget { this.isHistory = false, this.showDragHandle = true, required this.onTap, + this.index, }); @override @@ -333,10 +333,23 @@ class _QueueTile extends StatelessWidget { // --- Drag Handle on the Right --- if (!isHistory && !isActive && showDragHandle) - const Padding( - padding: EdgeInsets.only(left: 8.0), - child: Icon(Icons.drag_handle, color: Colors.grey, size: 20), - ), + if (index != null) + ReorderableDragStartListener( + index: index!, + child: const Padding( + padding: EdgeInsets.all(12.0), + child: Icon( + Icons.drag_handle, + color: Colors.grey, + size: 22, + ), + ), + ) + else + const Padding( + padding: EdgeInsets.only(left: 8.0), + child: Icon(Icons.drag_handle, color: Colors.grey, size: 20), + ), ], ), ), diff --git a/lib/features/player/presentation/providers/player_provider.dart b/lib/features/player/presentation/providers/player_provider.dart index 07e24153..444c75d4 100644 --- a/lib/features/player/presentation/providers/player_provider.dart +++ b/lib/features/player/presentation/providers/player_provider.dart @@ -234,39 +234,56 @@ class PlayerNotifier extends Notifier { /// Internal helper to fetch full track metadata and waveform data. Future _updateTrackInBackground(String trackId) async { try { + final currentTrack = state.currentTrack; + if (currentTrack == null || currentTrack.id != trackId) return; + // Run both calls concurrently. - // Note: If one fails, Future.wait will throw immediately. - // We wrap individual calls to allow partial success if possible, - // or just catch the whole thing. final results = await Future.wait([ ref.read(getTrackDetailsUseCaseProvider).call(trackId).catchError((e) { debugPrint( '[PlayerNotifier] Failed to fetch track details for $trackId: $e', ); - return state.currentTrack!; // Fallback to existing track info + return currentTrack; // Fallback to existing }), ref.read(getWaveformUseCaseProvider).call(trackId).catchError((e) { debugPrint( '[PlayerNotifier] Failed to fetch waveform for $trackId: $e', ); - return []; // Fallback to empty waveform + return []; // Fallback to empty }), ]); final fullTrack = results[0] as Track; final waveform = results[1] as List; - // Only update if we actually got new useful info - if (waveform.isNotEmpty || fullTrack != state.currentTrack) { - final updatedTrack = fullTrack.copyWith( - waveformData: waveform.isNotEmpty ? waveform : fullTrack.waveformData, - ); + // CRITICAL: Merge the new data with current data to ensure no fields are lost + // (e.g. if currentTrack has a streamUrl but fullTrack doesn't, keep current) + final mergedTrack = currentTrack.copyWith( + userId: fullTrack.userId.isNotEmpty + ? fullTrack.userId + : currentTrack.userId, + artistUsername: fullTrack.artistUsername ?? currentTrack.artistUsername, + artistPfp: fullTrack.artistPfp ?? currentTrack.artistPfp, + waveformData: waveform.isNotEmpty + ? waveform + : (fullTrack.waveformData ?? currentTrack.waveformData), + isLiked: fullTrack.isLiked, + isReposted: fullTrack.isReposted, + isArtistFollowed: fullTrack.isArtistFollowed, + playCount: fullTrack.playCount > 0 + ? fullTrack.playCount + : currentTrack.playCount, + ); + + // Only update if we actually got new useful info (waveform is the big one) + if (mergedTrack.waveformData != currentTrack.waveformData || + mergedTrack.userId != currentTrack.userId || + mergedTrack.artistUsername != currentTrack.artistUsername) { await ref .read(updateTrackInfoUseCaseProvider) - .call(trackId, updatedTrack); + .call(trackId, mergedTrack); } } catch (e) { - // Catch-all for any other unexpected errors in the background update flow debugPrint( '[PlayerNotifier] Critical error in _updateTrackInBackground: $e', ); diff --git a/lib/features/player/presentation/providers/queue_provider.dart b/lib/features/player/presentation/providers/queue_provider.dart index 05d13102..e54287b4 100644 --- a/lib/features/player/presentation/providers/queue_provider.dart +++ b/lib/features/player/presentation/providers/queue_provider.dart @@ -12,29 +12,27 @@ final queueStateProvider = NotifierProvider(() { }); class QueueNotifier extends Notifier { - String? _lastFetchedRelatedId; + String? _lastFetchedSeedId; @override AppQueueState build() { ref.listen(playerStateProvider, (previous, next) { - // ── Unified Reactive Logic ───────────────────────────────────────────── - // We react differently depending on WHAT changed in the player. - final oldId = previous?.currentTrack?.id; final newId = next.currentTrack?.id; - final oldIndex = previous?.queueIndex; - final newIndex = next.queueIndex; + final oldWave = previous?.currentTrack?.waveformData; + final newWave = next.currentTrack?.waveformData; // 1. TRACK CHANGED (Auto-advance or Skip) - // We sync state and check if we need to fetch more recommendations. - if (oldId != newId && newId != null) { - _syncWithHardware(newIndex ?? 0); + if (newId != null && oldId != newId) { + _syncWithHardware(next.queueIndex ?? 0); _checkAndFetchRelated(); } - // 2. INDEX CHANGED (Same track, different position in hardware queue) - // This happens during reorders or manual queue syncs. We just sync state. - else if (oldIndex != newIndex && newIndex != null) { - _syncWithHardware(newIndex); + // 2. METADATA RESOLVED (Waveform or detail update for the same track) + else if (newId != null && + oldId == newId && + oldWave == null && + newWave != null) { + _syncWithHardware(next.queueIndex ?? 0); } }); @@ -76,60 +74,86 @@ class QueueNotifier extends Notifier { ); } - /// Proactively fetches discovery tracks BEFORE the manual queue ends. + /// Super simple proactive fetch: uses the LAST track in queue as seed. Future _checkAndFetchRelated() async { final upcoming = state.upcomingTracks; final current = state.currentTrack; - if (current == null) return; + if (current == null || state.isLoadingRecommendations) return; + + // 1. Identify the seed: The absolute last track we know about. + final seedItem = upcoming.isNotEmpty ? upcoming.last : current; + final seedId = seedItem.track.id; - // Threshold: Fetch when total remaining tracks are 5 or fewer. - if (upcoming.length <= 5 && - !state.isLoadingRecommendations && - _lastFetchedRelatedId != current.track.id) { - _lastFetchedRelatedId = current.track.id; + // 2. Threshold: If we have 5 or fewer tracks left in the queue. + // 3. Guard: Don't fetch for the same seed twice in a row. + if (upcoming.length <= 5 && _lastFetchedSeedId != seedId) { + _lastFetchedSeedId = seedId; state = state.copyWith(isLoadingRecommendations: true); try { final relatedTracks = await ref .read(getRelatedTracksUseCaseProvider) - .call(current.track.id); - - final existingIds = { - ...state.history.map((e) => e.track.id), - ...state.upcomingTracks.map((e) => e.track.id), - current.track.id, - }; - - final newRelated = relatedTracks - .where((t) => !existingIds.contains(t.id)) - .map( - (t) => QueueItem( - track: t, - queueBucket: 'context', - sourceType: 'related', - sourceId: current.track.id, - isRecommended: true, - ), - ) + .call(seedId); + + // Simple duplicate filter (don't add what's already upcoming or playing) + final upcomingIds = upcoming.map((e) => e.track.id).toSet(); + final forbiddenIds = {...upcomingIds, current.track.id}; + + final filteredTracks = relatedTracks + .where((t) => !forbiddenIds.contains(t.id)) .toList(); - if (newRelated.isNotEmpty) { - // Hardware sync + if (filteredTracks.isNotEmpty) { + // RESOLVE FULL METADATA AND WAVEFORMS BEFORE ADDING + // This ensures the queue is never filled with skeletons + final fullTracks = await Future.wait( + filteredTracks.map((t) async { + try { + // Fetch details and waveform concurrently + final results = await Future.wait([ + ref.read(getTrackDetailsUseCaseProvider).call(t.id), + ref + .read(getWaveformUseCaseProvider) + .call(t.id) + .catchError((_) => []), + ]); + final track = results[0] as Track; + final wave = results[1] as List; + return track.copyWith( + waveformData: wave.isNotEmpty ? wave : track.waveformData, + ); + } catch (_) { + return t; // Fallback to skeleton if resolution fails + } + }), + ); + + final newItems = fullTracks + .map( + (t) => QueueItem( + track: t, + queueBucket: 'context', + sourceType: 'related', + sourceId: seedId, + isRecommended: true, + ), + ) + .toList(); + + // Append to hardware and UI await ref .read(appendTracksUseCaseProvider) - .call(newRelated.map((e) => e.track).toList()); - - // UI sync + .call(newItems.map((e) => e.track).toList()); state = state.copyWith( - upcomingTracks: [...state.upcomingTracks, ...newRelated], + upcomingTracks: [...state.upcomingTracks, ...newItems], isLoadingRecommendations: false, ); } else { state = state.copyWith(isLoadingRecommendations: false); } } catch (e) { - debugPrint('[QueueNotifier] Proactive fetch failed: $e'); + debugPrint('[QueueNotifier] Fetch failed: $e'); state = state.copyWith(isLoadingRecommendations: false); } } @@ -146,7 +170,7 @@ class QueueNotifier extends Notifier { } final tappedTrack = tracks[initialIndex]; - _lastFetchedRelatedId = null; + _lastFetchedSeedId = null; final localItems = tracks.asMap().entries.map((entry) { final t = entry.value; diff --git a/lib/features/playlist/presentation/screens/related_tracks_screen.dart b/lib/features/playlist/presentation/screens/related_tracks_screen.dart index ebe4b7e6..c18a8ab0 100644 --- a/lib/features/playlist/presentation/screens/related_tracks_screen.dart +++ b/lib/features/playlist/presentation/screens/related_tracks_screen.dart @@ -44,6 +44,7 @@ import '../../../../core/theme/app_theme.dart'; import '../../../feed/presentation/providers/home_providers.dart'; import '../../../player/presentation/providers/queue_provider.dart'; import '../../../player/domain/entities/queue_state.dart'; +import '../../../track/presentation/providers/track_dependency_providers.dart'; import '../../domain/entities/playlist_track.dart'; import '../providers/playlist_provider.dart'; import '../providers/saved_content_provider.dart'; @@ -63,7 +64,8 @@ Track _toTrack(PlaylistTrack pt) => Track( userId: '', title: pt.title, artist: pt.artistName, - audioUrl: pt.id, + audioUrl: pt.audioUrl ?? pt.streamUrl ?? pt.id, + streamUrl: pt.streamUrl, coverImage: pt.coverUrl, duration: pt.duration, playCount: pt.playCount, @@ -186,10 +188,16 @@ class _BodyState extends ConsumerState<_Body> { Future _play(List list, int index) async { if (list.isEmpty || index >= list.length) return; try { + // Resolve full track entities before playing to ensure valid audioUrls + // match behavior in PlaylistDetailScreen + final fullTracks = await Future.wait( + list.map((t) => ref.read(getTrackDetailsUseCaseProvider).call(t.id)), + ); + await ref .read(queueStateProvider.notifier) .playQueue( - tracks: list, + tracks: fullTracks, initialIndex: index, context: QueueContext( type: QueueSource.station, diff --git a/lib/features/profile/presentation/widgets/info_bottom_sheet.dart b/lib/features/profile/presentation/widgets/info_bottom_sheet.dart index 28e90f99..2c4f8b97 100644 --- a/lib/features/profile/presentation/widgets/info_bottom_sheet.dart +++ b/lib/features/profile/presentation/widgets/info_bottom_sheet.dart @@ -114,11 +114,6 @@ class InfoBottomSheet extends StatelessWidget { key: const Key('profile_info_following_stat'), ), const SizedBox(width: 24), - _statItem( - '${profile.tracksCount}', - 'Tracks', - key: const Key('profile_info_tracks_stat'), - ), ], ), diff --git a/lib/features/track/presentation/widgets/bottom_sheets/track_options_modal.dart b/lib/features/track/presentation/widgets/bottom_sheets/track_options_modal.dart index 9943aa60..1912edfa 100644 --- a/lib/features/track/presentation/widgets/bottom_sheets/track_options_modal.dart +++ b/lib/features/track/presentation/widgets/bottom_sheets/track_options_modal.dart @@ -3,7 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:share_plus/share_plus.dart'; import '../../../../../core/domain/entities/track.dart'; import '../../../../../core/presentation/pages/report_page.dart'; @@ -44,27 +44,24 @@ class TrackOptionsModal extends ConsumerWidget { }); // --- Share Methods --- - Future _launchUrl(String urlString) async { - final url = Uri.parse(urlString); - if (await canLaunchUrl(url)) { - await launchUrl(url, mode: LaunchMode.externalApplication); - } + + String _getAzureShareUrl(Track track) { + final username = (track.artistUsername ?? track.artist) + .replaceAll(' ', '') + .toLowerCase(); + return 'https://gray-grass-0ab138600.7.azurestaticapps.net/$username/${track.id}'; } void _shareToWhatsApp(BuildContext context) { - final text = - 'Listen Now On Rythmify: https://rythmify.com/tracks/${track.id}'; - final urlString = 'whatsapp://send?text=${Uri.encodeComponent(text)}'; - _launchUrl(urlString); + final text = 'Listen Now On Rythmify: ${_getAzureShareUrl(track)}'; Navigator.pop(context); + Share.share(text); } void _shareToSMS(BuildContext context) { - final text = - 'Listen Now On Rythmify: https://rythmify.com/tracks/${track.id}'; - final urlString = 'sms:?body=${Uri.encodeComponent(text)}'; - _launchUrl(urlString); + final text = 'Listen Now On Rythmify: ${_getAzureShareUrl(track)}'; Navigator.pop(context); + Share.share(text); } void _copyLink(BuildContext context) { @@ -213,7 +210,9 @@ class TrackOptionsModal extends ConsumerWidget { label: 'Message', onTap: () { Navigator.pop(context); - context.push('/home/inbox'); + final text = + 'Listen Now On Rythmify: ${_getAzureShareUrl(track)}'; + Share.share(text); }, ), _ShareIcon( @@ -466,8 +465,10 @@ class TrackOptionsModal extends ConsumerWidget { Navigator.pop(context); Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( - builder: (context) => - ReportPage(reportedContentId: syncedTrack.id), + builder: (context) => ReportPage( + reportedContentId: syncedTrack.id, + resourceType: 'track', + ), ), ); }, diff --git a/lib/features/track/presentation/widgets/track_card.dart b/lib/features/track/presentation/widgets/track_card.dart index 7cb939bf..fb15cf25 100644 --- a/lib/features/track/presentation/widgets/track_card.dart +++ b/lib/features/track/presentation/widgets/track_card.dart @@ -36,6 +36,10 @@ class TrackCard extends ConsumerWidget { /// Whether to show the bottom stats row (play count, duration, etc.). final bool showStats; + /// Optional widget to display on the right side (e.g. a delete bin). + /// If provided, this replaces the default more options icon. + final Widget? trailing; + /// Creates a [TrackCard]. const TrackCard({ super.key, @@ -45,6 +49,7 @@ class TrackCard extends ConsumerWidget { this.observePlayerState = true, this.showOptions = true, this.showStats = true, + this.trailing, }); // --- Formatting Helpers --- @@ -144,8 +149,10 @@ class TrackCard extends ConsumerWidget { ), ), - // 4. Trailing More Icon - if (showOptions) + // 4. Trailing Action + if (trailing != null) + trailing! + else if (showOptions) InkWell( key: Key('track_card_${track.id}_more_inkwell'), onTap: () {