nkbmusic/lib/shared/widgets/track_tile.dart
metatroncubeswdev ad8c4f1c6b App: speaker/Bluetooth picker, Settings & help, Privacy Policy, Terms, FAQ, song reports
- Now Playing shows where music plays and opens the phone's output switcher
- Settings hub: Wi-Fi-only downloads (default on), storage, sound, help, legal
- Privacy Policy and Terms bundled as Markdown (assets/legal), shown in-app
- 'Is this your song?' report form feeding takedown_requests (+ kind, user_id)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:00:16 -04:00

190 lines
6.8 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../app/router.dart';
import '../../app/theme.dart';
import '../../offline/download_store.dart';
import '../../audio/player_providers.dart';
import '../../core/models.dart';
import '../../data/catalog_repository.dart';
import '../../data/user_library.dart';
import 'artwork.dart';
class TrackTile extends ConsumerWidget {
const TrackTile({super.key, required this.track, required this.onTap, this.subtitle, this.card = false});
final Track track;
final VoidCallback onTap;
final String? subtitle;
/// Card style (rounded surface) used on Home; plain rows elsewhere.
final bool card;
@override
Widget build(BuildContext context, WidgetRef ref) {
final catalog = ref.watch(catalogProvider).requireValue;
final isCurrent = ref.watch(mediaItemProvider).value?.id == track.id;
final downloads = ref.watch(downloadsProvider);
final text = Theme.of(context).textTheme;
final row = InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: card ? 12 : 0, vertical: card ? 10 : 6),
child: Row(
children: [
TrackArtwork.track(track, size: card ? 60 : 52, radius: 10),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
track.titleLatin,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: text.titleMedium?.copyWith(color: isCurrent ? NkColors.sun : NkColors.cream),
),
const SizedBox(height: 2),
Row(
children: [
if (downloads.isDownloaded(track.id))
const Padding(
padding: EdgeInsets.only(right: 4),
child: Icon(Icons.download_done_rounded, size: 16, color: NkColors.teaLeaf),
)
else if (downloads.progress.containsKey(track.id))
const Padding(
padding: EdgeInsets.only(right: 6),
child: SizedBox.square(dimension: 12, child: CircularProgressIndicator(strokeWidth: 2)),
),
Expanded(
child: Text(
subtitle ?? '${catalog.creditLine(track)} · ${formatDuration(track.duration)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: text.bodyMedium?.copyWith(color: NkColors.muted),
),
),
],
),
],
),
),
TrackMenuButton(track: track),
],
),
),
);
if (!card) return row;
return Material(color: NkColors.surface, borderRadius: BorderRadius.circular(16), child: row);
}
}
class TrackMenuButton extends ConsumerWidget {
const TrackMenuButton({super.key, required this.track});
final Track track;
@override
Widget build(BuildContext context, WidgetRef ref) {
return IconButton(
tooltip: 'More options',
icon: const Icon(Icons.more_vert, color: NkColors.muted),
onPressed: () => showTrackActions(context, ref, track),
);
}
}
Future<void> showTrackActions(BuildContext context, WidgetRef ref, Track track) {
final catalog = ref.read(catalogProvider).requireValue;
final controller = ref.read(playerControllerProvider);
final liked = ref.read(likedSongsProvider).contains(track.id);
final downloaded = ref.read(downloadsProvider).isDownloaded(track.id);
final artists = catalog.artistsOf(track);
void done(String message) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
}
return showModalBottomSheet<void>(
context: context,
backgroundColor: NkColors.surface,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: TrackArtwork.track(track, size: 48),
title: Text(track.titleLatin, style: Theme.of(context).textTheme.titleMedium),
subtitle: Text(catalog.creditLine(track)),
),
const Divider(color: NkColors.outline),
ListTile(
leading: const Icon(Icons.playlist_play),
title: const Text('Play next'),
onTap: () {
controller.playNext(track);
done('Playing next');
},
),
ListTile(
leading: const Icon(Icons.queue_music),
title: const Text('Add to queue'),
onTap: () {
controller.addToQueue(track);
done('Added to queue');
},
),
ListTile(
leading: Icon(liked ? Icons.favorite : Icons.favorite_border, color: liked ? NkColors.like : null),
title: Text(liked ? 'Remove from liked songs' : 'Add to liked songs'),
onTap: () {
ref.read(likedSongsProvider.notifier).toggle(track.id);
done(liked ? 'Removed from liked songs' : 'Added to liked songs');
},
),
ListTile(
leading: Icon(downloaded ? Icons.download_done_rounded : Icons.download_rounded,
color: downloaded ? NkColors.teaLeaf : null),
title: Text(downloaded ? 'Remove download' : 'Download'),
onTap: () {
if (downloaded) {
ref.read(downloadStoreProvider).remove(track.id);
done('Download removed');
} else if (!isSignedIn) {
Navigator.of(sheetContext).pop();
context.push('/login?mode=signup');
} else {
controller.download([track]).then((problem) => done(problem ?? 'Downloading. Find it in Downloads.'));
}
},
),
ListTile(
leading: const Icon(Icons.flag_outlined),
title: const Text('Is this your song? Report'),
onTap: () {
Navigator.of(sheetContext).pop();
context.push('/report?track=${track.id}');
},
),
for (final a in artists)
ListTile(
leading: const Icon(Icons.person_outline),
title: Text('Go to ${a.name}'),
onTap: () {
Navigator.of(sheetContext).pop();
context.push('/artist/${a.id}');
},
),
],
),
),
);
}