🎉 Initial Commit

This commit is contained in:
2025-12-14 21:25:24 +08:00
commit 49854b44e1
151 changed files with 10034 additions and 0 deletions

View File

@@ -0,0 +1,557 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../data/track_repository.dart';
import '../../providers/audio_provider.dart';
import '../../data/playlist_repository.dart';
import '../../data/db.dart';
import '../tabs/albums_tab.dart';
import '../tabs/playlists_tab.dart';
class LibraryScreen extends HookConsumerWidget {
const LibraryScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// We can define a stream provider locally or in repository file.
// For now, using StreamBuilder is easiest since `watchAllTracks` returns a Stream.
// Or better: `ref.watch(trackListStreamProvider)`.
// Let's assume we use StreamBuilder for now to avoid creating another file/provider on the fly.
final repo = ref.watch(trackRepositoryProvider.notifier);
final selectedTrackIds = useState<Set<int>>({});
final isSelectionMode = selectedTrackIds.value.isNotEmpty;
void toggleSelection(int id) {
final newSet = Set<int>.from(selectedTrackIds.value);
if (newSet.contains(id)) {
newSet.remove(id);
} else {
newSet.add(id);
}
selectedTrackIds.value = newSet;
}
void clearSelection() {
selectedTrackIds.value = {};
}
return DefaultTabController(
length: 3,
child: Scaffold(
appBar: isSelectionMode
? AppBar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: clearSelection,
),
title: Text('${selectedTrackIds.value.length} selected'),
backgroundColor: Theme.of(context).primaryColorDark,
actions: [
IconButton(
icon: const Icon(Icons.playlist_add),
tooltip: 'Add to Playlist',
onPressed: () {
_batchAddToPlaylist(
context,
ref,
selectedTrackIds.value.toList(),
clearSelection,
);
},
),
IconButton(
icon: const Icon(Icons.delete),
tooltip: 'Delete',
onPressed: () {
_batchDelete(
context,
ref,
selectedTrackIds.value.toList(),
clearSelection,
);
},
),
],
)
: AppBar(
title: const Text('Library'),
bottom: const TabBar(
tabs: [
Tab(text: 'Tracks', icon: Icon(Icons.audiotrack)),
Tab(text: 'Albums', icon: Icon(Icons.album)),
Tab(text: 'Playlists', icon: Icon(Icons.queue_music)),
],
),
actions: [
IconButton(
icon: const Icon(Icons.add_circle_outline),
tooltip: 'Add Tracks',
onPressed: () async {
final result = await FilePicker.platform.pickFiles(
type: FileType.audio,
allowMultiple: true,
);
if (result != null) {
// Collect paths
final paths = result.files
.map((f) => f.path)
.whereType<String>()
.toList();
if (paths.isNotEmpty) {
await repo.importFiles(paths);
}
}
},
),
],
),
body: TabBarView(
children: [
// Tracks Tab (Existing Logic)
StreamBuilder<List<Track>>(
stream: repo.watchAllTracks(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final tracks = snapshot.data!;
if (tracks.isEmpty) {
return const Center(child: Text('No tracks yet. Add some!'));
}
return ListView.builder(
itemCount: tracks.length,
itemBuilder: (context, index) {
final track = tracks[index];
final isSelected = selectedTrackIds.value.contains(
track.id,
);
if (isSelectionMode) {
return ListTile(
selected: isSelected,
selectedTileColor: Colors.white10,
leading: Checkbox(
value: isSelected,
onChanged: (_) => toggleSelection(track.id),
),
title: Text(
track.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${track.artist ?? 'Unknown Artist'}${_formatDuration(track.duration)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: () => toggleSelection(track.id),
);
}
return Dismissible(
key: Key('track_${track.id}'),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete, color: Colors.white),
),
confirmDismiss: (direction) async {
return await showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Delete Track?'),
content: Text(
'Are you sure you want to delete "${track.title}"? This cannot be undone.',
),
actions: [
TextButton(
onPressed: () =>
Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () =>
Navigator.of(context).pop(true),
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
child: const Text('Delete'),
),
],
);
},
);
},
onDismissed: (direction) {
ref
.read(trackRepositoryProvider.notifier)
.deleteTrack(track.id);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Deleted "${track.title}"')),
);
},
child: ListTile(
leading: AspectRatio(
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
color: Colors.grey[800],
borderRadius: BorderRadius.circular(8),
image: track.artUri != null
? DecorationImage(
image: FileImage(File(track.artUri!)),
fit: BoxFit.cover,
)
: null,
),
child: track.artUri == null
? const Icon(
Icons.music_note,
color: Colors.white54,
)
: null,
),
),
title: Text(
track.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${track.artist ?? 'Unknown Artist'}${_formatDuration(track.duration)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: isSelectionMode
? null
: IconButton(
icon: const Icon(Icons.more_vert),
onPressed: () {
_showTrackOptions(context, ref, track);
},
),
onTap: () {
final audio = ref.read(audioHandlerProvider);
audio.setSource(track.path);
audio.play();
},
onLongPress: () {
// Enter selection mode
toggleSelection(track.id);
},
),
);
},
);
},
),
// Albums Tab
const AlbumsTab(),
// Playlists Tab
const PlaylistsTab(),
],
),
),
);
}
void _showTrackOptions(BuildContext context, WidgetRef ref, Track track) {
showModalBottomSheet(
context: context,
builder: (context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.playlist_add),
title: const Text('Add to Playlist'),
onTap: () {
Navigator.pop(context);
_showAddToPlaylistDialog(context, ref, track);
},
),
ListTile(
leading: const Icon(Icons.edit),
title: const Text('Edit Metadata'),
onTap: () {
Navigator.pop(context);
_showEditDialog(context, ref, track);
},
),
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: const Text(
'Delete Track',
style: TextStyle(color: Colors.red),
),
onTap: () {
Navigator.pop(context);
ref
.read(trackRepositoryProvider.notifier)
.deleteTrack(track.id);
},
),
],
),
);
},
);
}
void _showAddToPlaylistDialog(
BuildContext context,
WidgetRef ref,
Track track,
) {
showDialog(
context: context,
builder: (context) {
// Fetch playlists
// Note: Using a hook/provider inside dialog builder might need a Consumer or similar if stream updates.
// For simplicity, we'll assume the user wants to pick from *current* playlists.
// Or we can use a Consumer widget inside the dialog.
return AlertDialog(
title: const Text('Add to Playlist'),
content: SizedBox(
width: double.maxFinite,
child: Consumer(
builder: (context, ref, child) {
final playlistsAsync = ref
.watch(playlistRepositoryProvider.notifier)
.watchAllPlaylists();
return StreamBuilder<List<Playlist>>(
stream: playlistsAsync,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final playlists = snapshot.data!;
if (playlists.isEmpty) {
return const Text(
'No playlists available. Create one first!',
);
}
return ListView.builder(
shrinkWrap: true,
itemCount: playlists.length,
itemBuilder: (context, index) {
final playlist = playlists[index];
return ListTile(
title: Text(playlist.name),
onTap: () {
ref
.read(playlistRepositoryProvider.notifier)
.addToPlaylist(playlist.id, track.id);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Added to ${playlist.name}'),
),
);
},
);
},
);
},
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
],
);
},
);
}
void _showEditDialog(BuildContext context, WidgetRef ref, Track track) {
final titleController = TextEditingController(text: track.title);
final artistController = TextEditingController(text: track.artist);
final albumController = TextEditingController(text: track.album);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Edit Track'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: titleController,
decoration: const InputDecoration(labelText: 'Title'),
),
TextField(
controller: artistController,
decoration: const InputDecoration(labelText: 'Artist'),
),
TextField(
controller: albumController,
decoration: const InputDecoration(labelText: 'Album'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
ref
.read(trackRepositoryProvider.notifier)
.updateMetadata(
trackId: track.id,
title: titleController.text,
artist: artistController.text,
album: albumController.text,
);
Navigator.pop(context);
},
child: const Text('Save'),
),
],
),
);
}
String _formatDuration(int? durationMs) {
if (durationMs == null) return '--:--';
final d = Duration(milliseconds: durationMs);
final minutes = d.inMinutes;
final seconds = d.inSeconds % 60;
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
void _batchAddToPlaylist(
BuildContext context,
WidgetRef ref,
List<int> trackIds,
VoidCallback onSuccess,
) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Add to Playlist'),
content: SizedBox(
width: double.maxFinite,
child: Consumer(
builder: (context, ref, child) {
final playlistsAsync = ref
.watch(playlistRepositoryProvider.notifier)
.watchAllPlaylists();
return StreamBuilder<List<Playlist>>(
stream: playlistsAsync,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final playlists = snapshot.data!;
if (playlists.isEmpty) {
return const Text('No playlists available.');
}
return ListView.builder(
shrinkWrap: true,
itemCount: playlists.length,
itemBuilder: (context, index) {
final playlist = playlists[index];
return ListTile(
title: Text(playlist.name),
onTap: () async {
final repo = ref.read(
playlistRepositoryProvider.notifier,
);
for (final id in trackIds) {
await repo.addToPlaylist(playlist.id, id);
}
if (!context.mounted) return;
Navigator.pop(context);
onSuccess();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Added ${trackIds.length} tracks to ${playlist.name}',
),
),
);
},
);
},
);
},
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
],
);
},
);
}
void _batchDelete(
BuildContext context,
WidgetRef ref,
List<int> trackIds,
VoidCallback onSuccess,
) async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Tracks?'),
content: Text(
'Are you sure you want to delete ${trackIds.length} tracks? '
'This will remove them from your device.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Delete'),
),
],
),
);
if (confirm == true) {
final repo = ref.read(trackRepositoryProvider.notifier);
for (final id in trackIds) {
await repo.deleteTrack(id);
}
onSuccess();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Deleted ${trackIds.length} tracks')),
);
}
}
}

View File

@@ -0,0 +1,240 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import '../../providers/audio_provider.dart';
import '../../logic/metadata_service.dart';
class PlayerScreen extends HookConsumerWidget {
const PlayerScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final audioHandler = ref.watch(audioHandlerProvider);
final player = audioHandler.player;
return Scaffold(
appBar: AppBar(
title: const Text('Now Playing'),
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.keyboard_arrow_down),
onPressed: () => Navigator.of(context).pop(),
),
),
body: StreamBuilder<Playlist>(
stream: player.stream.playlist,
initialData: player.state.playlist,
builder: (context, snapshot) {
final index = snapshot.data?.index ?? 0;
final medias = snapshot.data?.medias ?? [];
if (medias.isEmpty || index < 0 || index >= medias.length) {
return const Center(child: Text('No media selected'));
}
final media = medias[index];
final path = Uri.decodeFull(Uri.parse(media.uri).path);
final metadataAsync = ref.watch(trackMetadataProvider(path));
return Column(
children: [
// Cover Art
Expanded(
flex: 3,
child: Padding(
padding: const EdgeInsets.all(32.0),
child: AspectRatio(
aspectRatio: 1,
child: metadataAsync.when(
data: (meta) => Container(
decoration: BoxDecoration(
color: Colors.grey[800],
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
image: meta.artBytes != null
? DecorationImage(
image: MemoryImage(meta.artBytes!),
fit: BoxFit.cover,
)
: null,
),
child: meta.artBytes == null
? const Center(
child: Icon(
Icons.music_note,
size: 80,
color: Colors.white54,
),
)
: null,
),
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (_, __) => Container(
decoration: BoxDecoration(
color: Colors.grey[800],
borderRadius: BorderRadius.circular(24),
),
child: const Center(
child: Icon(
Icons.error_outline,
size: 80,
color: Colors.white54,
),
),
),
),
),
),
),
// Track Info
Column(
children: [
metadataAsync.when(
data: (meta) => Text(
meta.title ?? Uri.parse(media.uri).pathSegments.last,
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
loading: () => const SizedBox(height: 32),
error: (_, __) =>
Text(Uri.parse(media.uri).pathSegments.last),
),
const SizedBox(height: 8),
metadataAsync.when(
data: (meta) => Text(
meta.artist ?? 'Unknown Artist',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
loading: () => const SizedBox(height: 24),
error: (_, __) => const SizedBox.shrink(),
),
],
),
const SizedBox(height: 32),
// Lyrics (Placeholder)
Expanded(
flex: 2,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest
.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(16),
),
child: const Center(
child: Text(
'No Lyrics Available',
style: TextStyle(fontStyle: FontStyle.italic),
),
),
),
),
const SizedBox(height: 24),
// Progress Bar
StreamBuilder<Duration>(
stream: player.stream.position,
builder: (context, snapshot) {
final position = snapshot.data ?? Duration.zero;
return StreamBuilder<Duration>(
stream: player.stream.duration,
builder: (context, durationSnapshot) {
final totalDuration =
durationSnapshot.data ?? Duration.zero;
final max = totalDuration.inSeconds.toDouble();
final value = position.inSeconds.toDouble().clamp(
0.0,
max > 0 ? max : 0.0,
);
return Column(
children: [
Slider(
value: value,
min: 0,
max: max > 0 ? max : 1.0,
onChanged: (val) {
player.seek(Duration(seconds: val.toInt()));
},
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 24.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(_formatDuration(position)),
Text(_formatDuration(totalDuration)),
],
),
),
],
);
},
);
},
),
const SizedBox(height: 16),
// Controls
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.skip_previous, size: 32),
onPressed: player.previous,
),
const SizedBox(width: 16),
StreamBuilder<bool>(
stream: player.stream.playing,
builder: (context, snapshot) {
final playing = snapshot.data ?? false;
return IconButton.filled(
icon: Icon(
playing ? Icons.pause : Icons.play_arrow,
size: 48,
),
onPressed: playing ? player.pause : player.play,
iconSize: 48,
);
},
),
const SizedBox(width: 16),
IconButton(
icon: const Icon(Icons.skip_next, size: 32),
onPressed: player.next,
),
],
),
const SizedBox(height: 48),
],
);
},
),
);
}
String _formatDuration(Duration d) {
final minutes = d.inMinutes;
final seconds = d.inSeconds % 60;
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
}

27
lib/ui/shell.dart Normal file
View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'screens/library_screen.dart';
import 'widgets/mini_player.dart';
class Shell extends StatelessWidget {
const Shell({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Stack(
children: [
// Main Content
Positioned.fill(
child: LibraryScreen(),
// Note: LibraryScreen might need padding at bottom to avoid occlusion by mini player
// We can wrap LibraryScreen content or handle it there.
// For now, let's just place it.
),
// Mini Player
Positioned(left: 0, right: 0, bottom: 0, child: MiniPlayer()),
],
),
);
}
}

View File

@@ -0,0 +1,89 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../data/playlist_repository.dart';
class AlbumsTab extends HookConsumerWidget {
const AlbumsTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final repo = ref.watch(playlistRepositoryProvider.notifier);
return StreamBuilder<List<AlbumData>>(
stream: repo.watchAllAlbums(),
builder: (context, snapshot) {
if (!snapshot.hasData)
return const Center(child: CircularProgressIndicator());
final albums = snapshot.data!;
if (albums.isEmpty) {
return const Center(child: Text('No albums found'));
}
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
childAspectRatio: 0.8,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: albums.length,
itemBuilder: (context, index) {
final album = albums[index];
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
// Navigate to Album details (list of tracks)
// For now just show snackbar or simple push
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Open ${album.album}')),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: album.artUri != null
? Image.file(File(album.artUri!), fit: BoxFit.cover)
: Container(
color: Colors.grey[800],
child: const Icon(
Icons.album,
size: 48,
color: Colors.white54,
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
album.album,
style: Theme.of(context).textTheme.titleSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
album.artist,
style: Theme.of(context).textTheme.bodySmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
);
},
);
},
);
}
}

View File

@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../data/db.dart';
import '../../data/playlist_repository.dart';
class PlaylistsTab extends HookConsumerWidget {
const PlaylistsTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final repo = ref.watch(playlistRepositoryProvider.notifier);
return Scaffold(
floatingActionButton: FloatingActionButton(
onPressed: () async {
final nameController = TextEditingController();
final name = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('New Playlist'),
content: TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Playlist Name'),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, nameController.text),
child: const Text('Create'),
),
],
),
);
if (name != null && name.isNotEmpty) {
await repo.createPlaylist(name);
}
},
child: const Icon(Icons.add),
),
body: StreamBuilder<List<Playlist>>(
stream: repo.watchAllPlaylists(),
builder: (context, snapshot) {
if (!snapshot.hasData)
return const Center(child: CircularProgressIndicator());
final playlists = snapshot.data!;
if (playlists.isEmpty) {
return const Center(child: Text('No playlists yet'));
}
return ListView.builder(
itemCount: playlists.length,
itemBuilder: (context, index) {
final playlist = playlists[index];
return ListTile(
leading: const Icon(Icons.queue_music),
title: Text(playlist.name),
subtitle: Text(
'${playlist.createdAt.day}/${playlist.createdAt.month}/${playlist.createdAt.year}',
),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () => repo.deletePlaylist(playlist.id),
),
onTap: () {
// Navigate to playlist details
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Open ${playlist.name}')),
);
},
);
},
);
},
),
);
}
}

View File

@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import '../../providers/audio_provider.dart';
import '../../logic/metadata_service.dart';
import '../screens/player_screen.dart';
class MiniPlayer extends HookConsumerWidget {
const MiniPlayer({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final audioHandler = ref.watch(audioHandlerProvider);
final player = audioHandler.player;
return StreamBuilder<Playlist>(
stream: player.stream.playlist,
initialData: player.state.playlist,
builder: (context, snapshot) {
final index = snapshot.data?.index ?? 0;
final medias = snapshot.data?.medias ?? [];
if (medias.isEmpty || index < 0 || index >= medias.length) {
return const SizedBox.shrink();
}
final media = medias[index];
final path = Uri.parse(media.uri).path;
// Using common parse for path if it's a file URI
final filePath = Uri.decodeFull(path);
final metadataAsync = ref.watch(trackMetadataProvider(filePath));
return GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => const PlayerScreen(),
),
);
},
child: Container(
height: 64,
width: double.infinity,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
border: Border(
top: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
width: 1,
),
),
),
child: Row(
children: [
// Cover Art (Small)
AspectRatio(
aspectRatio: 1,
child: metadataAsync.when(
data: (meta) => meta.artBytes != null
? Image.memory(meta.artBytes!, fit: BoxFit.cover)
: Container(
color: Colors.grey[800],
child: const Icon(
Icons.music_note,
color: Colors.white54,
),
),
loading: () => Container(color: Colors.grey[800]),
error: (_, __) => Container(color: Colors.grey[800]),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
metadataAsync.when(
data: (meta) => Text(
meta.title ??
Uri.parse(media.uri).pathSegments.last,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
loading: () => const Text('Loading...'),
error: (_, __) =>
Text(Uri.parse(media.uri).pathSegments.last),
),
metadataAsync.when(
data: (meta) => Text(
meta.artist ?? 'Unknown Artist',
style: Theme.of(context).textTheme.bodySmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
),
],
),
),
),
StreamBuilder<bool>(
stream: player.stream.playing,
builder: (context, snapshot) {
final playing = snapshot.data ?? false;
return IconButton(
icon: Icon(playing ? Icons.pause : Icons.play_arrow),
onPressed: playing ? player.pause : player.play,
);
},
),
const SizedBox(width: 8),
],
),
),
);
},
);
}
}