🎉 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

59
lib/data/db.dart Normal file
View File

@@ -0,0 +1,59 @@
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
part 'db.g.dart';
class Tracks extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get title => text()();
TextColumn get artist => text().nullable()();
TextColumn get album => text().nullable()();
IntColumn get duration => integer().nullable()(); // Duration in milliseconds
TextColumn get path => text().unique()();
TextColumn get artUri => text().nullable()(); // Path to local cover art
DateTimeColumn get addedAt => dateTime().withDefault(currentDateAndTime)();
}
class Playlists extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get name => text()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
}
class PlaylistEntries extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get playlistId => integer().references(Playlists, #id)();
IntColumn get trackId =>
integer().references(Tracks, #id, onDelete: KeyAction.cascade)();
DateTimeColumn get addedAt => dateTime().withDefault(currentDateAndTime)();
}
@DriftDatabase(tables: [Tracks, Playlists, PlaylistEntries])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@override
int get schemaVersion => 3; // Bump version
@override
MigrationStrategy get migration {
return MigrationStrategy(
onCreate: (Migrator m) async {
await m.createAll();
},
onUpgrade: (Migrator m, int from, int to) async {
if (from < 2) {
await m.addColumn(tracks, tracks.artUri);
}
if (from < 3) {
await m.createTable(playlists);
await m.createTable(playlistEntries);
}
},
);
}
static QueryExecutor _openConnection() {
return driftDatabase(name: 'groovybox_db');
}
}

2089
lib/data/db.g.dart Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,110 @@
import 'package:drift/drift.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../providers/db_provider.dart';
import 'db.dart';
part 'playlist_repository.g.dart';
@riverpod
class PlaylistRepository extends _$PlaylistRepository {
@override
FutureOr<void> build() {}
// --- Playlists ---
Stream<List<Playlist>> watchAllPlaylists() {
final db = ref.watch(databaseProvider);
return (db.select(
db.playlists,
)..orderBy([(p) => OrderingTerm(expression: p.createdAt)])).watch();
}
Stream<List<Track>> watchPlaylistTracks(int playlistId) {
final db = ref.watch(databaseProvider);
// Join PlaylistsEntries with Tracks
final query =
db.select(db.playlistEntries).join([
innerJoin(
db.tracks,
db.tracks.id.equalsExp(db.playlistEntries.trackId),
),
])
..where(db.playlistEntries.playlistId.equals(playlistId))
..orderBy([OrderingTerm(expression: db.playlistEntries.addedAt)]);
return query.map((row) => row.readTable(db.tracks)).watch();
}
Future<int> createPlaylist(String name) async {
final db = ref.read(databaseProvider);
return db.into(db.playlists).insert(PlaylistsCompanion.insert(name: name));
}
Future<void> addToPlaylist(int playlistId, int trackId) async {
final db = ref.read(databaseProvider);
await db
.into(db.playlistEntries)
.insert(
PlaylistEntriesCompanion.insert(
playlistId: playlistId,
trackId: trackId,
),
mode: InsertMode.insertOrIgnore, // Prevent dupes if needed, or allow
);
}
Future<void> deletePlaylist(int playlistId) async {
final db = ref.read(databaseProvider);
// entries cascade delete
await (db.delete(db.playlists)..where((p) => p.id.equals(playlistId))).go();
}
Future<void> removeFromPlaylist(int playlistId, int trackId) async {
final db = ref.read(databaseProvider);
await (db.delete(db.playlistEntries)..where(
(e) => e.playlistId.equals(playlistId) & e.trackId.equals(trackId),
))
.go();
}
// --- Albums ---
Stream<List<AlbumData>> watchAllAlbums() {
final db = ref.watch(databaseProvider);
// Distinct albums by grouping
final query = db.selectOnly(db.tracks)
..addColumns([db.tracks.album, db.tracks.artist, db.tracks.artUri])
..groupBy([db.tracks.album, db.tracks.artist]);
return query.map((row) {
return AlbumData(
album: row.read(db.tracks.album) ?? 'Unknown Album',
artist: row.read(db.tracks.artist) ?? 'Unknown Artist',
artUri: row.read(db.tracks.artUri),
);
}).watch();
}
Stream<List<Track>> watchAlbumTracks(String albumName) {
final db = ref.watch(databaseProvider);
return (db.select(
db.tracks,
)..where((t) => t.album.equals(albumName))).watch();
}
}
class AlbumData {
final String album;
final String artist;
final String? artUri;
AlbumData({required this.album, required this.artist, this.artUri});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AlbumData && album == other.album && artist == other.artist;
@override
int get hashCode => Object.hash(album, artist);
}

View File

@@ -0,0 +1,56 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'playlist_repository.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(PlaylistRepository)
const playlistRepositoryProvider = PlaylistRepositoryProvider._();
final class PlaylistRepositoryProvider
extends $AsyncNotifierProvider<PlaylistRepository, void> {
const PlaylistRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'playlistRepositoryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$playlistRepositoryHash();
@$internal
@override
PlaylistRepository create() => PlaylistRepository();
}
String _$playlistRepositoryHash() =>
r'9a76fa2443bfb810b75b26adaf6225de48049a3a';
abstract class _$PlaylistRepository extends $AsyncNotifier<void> {
FutureOr<void> build();
@$mustCallSuper
@override
void runBuild() {
build();
final ref = this.ref as $Ref<AsyncValue<void>, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<void>, void>,
AsyncValue<void>,
Object?,
Object?
>;
element.handleValue(ref, null);
}
}

View File

@@ -0,0 +1,131 @@
import 'dart:io';
import 'package:flutter_media_metadata/flutter_media_metadata.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:drift/drift.dart';
import '../providers/db_provider.dart';
import 'db.dart';
part 'track_repository.g.dart';
@riverpod
class TrackRepository extends _$TrackRepository {
@override
FutureOr<void> build() {}
Stream<List<Track>> watchAllTracks() {
final db = ref.watch(databaseProvider);
return (db.select(
db.tracks,
)..orderBy([(t) => OrderingTerm(expression: t.title)])).watch();
}
Future<void> importFiles(List<String> filePaths) async {
final db = ref.read(databaseProvider);
final appDir = await getApplicationDocumentsDirectory();
final musicDir = Directory(p.join(appDir.path, 'music'));
final artDir = Directory(p.join(appDir.path, 'art'));
await musicDir.create(recursive: true);
await artDir.create(recursive: true);
for (final path in filePaths) {
final file = File(path);
if (!await file.exists()) continue;
try {
// 1. Copy file
final filename = p.basename(path);
// Ensure unique name to avoid overwriting or conflicts
final uniqueName = '${DateTime.now().millisecondsSinceEpoch}_$filename';
final newPath = p.join(musicDir.path, uniqueName);
await file.copy(newPath);
// 2. Extract Metadata
final metadata = await MetadataRetriever.fromFile(File(newPath));
String? artPath;
if (metadata.albumArt != null) {
final artName = '${uniqueName}_art.jpg';
final artFile = File(p.join(artDir.path, artName));
await artFile.writeAsBytes(metadata.albumArt!);
artPath = artFile.path;
}
// 3. Insert into DB
await db
.into(db.tracks)
.insert(
TracksCompanion.insert(
title:
metadata.trackName ?? p.basenameWithoutExtension(filename),
path: newPath, // Internal path
artist: Value(metadata.trackArtistNames?.join(', ')),
album: Value(metadata.albumName),
duration: Value(metadata.trackDuration), // Milliseconds
artUri: Value(artPath),
),
mode: InsertMode.insertOrIgnore,
);
} catch (e) {
print('Error importing file $path: $e');
// Continue to next file
}
}
}
Future<void> updateMetadata({
required int trackId,
required String title,
String? artist,
String? album,
}) async {
final db = ref.read(databaseProvider);
await (db.update(db.tracks)..where((t) => t.id.equals(trackId))).write(
TracksCompanion(
title: Value(title),
artist: Value(artist),
album: Value(album),
),
);
}
Future<void> deleteTrack(int trackId) async {
final db = ref.read(databaseProvider);
// 1. Get track info to find file path
final track = await (db.select(
db.tracks,
)..where((t) => t.id.equals(trackId))).getSingleOrNull();
if (track == null) return;
// 2. Delete from DB (cascade should handle playlist entries if configured, but we didn't set cascade on playlistEntries -> tracks properly maybe? CHECK DB)
// In db.dart: IntColumn get trackId => integer().references(Tracks, #id, onDelete: KeyAction.cascade)();
// So DB deletion cascades to entries.
await (db.delete(db.tracks)..where((t) => t.id.equals(trackId))).go();
// 3. Delete file
final file = File(track.path);
if (await file.exists()) {
try {
await file.delete();
} catch (e) {
print("Error deleting file: $e");
}
}
// 4. Delete art if exists
if (track.artUri != null) {
final artFile = File(track.artUri!);
if (await artFile.exists()) {
try {
await artFile.delete();
} catch (e) {
print("Error deleting art: $e");
}
}
}
}
}

View File

@@ -0,0 +1,55 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'track_repository.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(TrackRepository)
const trackRepositoryProvider = TrackRepositoryProvider._();
final class TrackRepositoryProvider
extends $AsyncNotifierProvider<TrackRepository, void> {
const TrackRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'trackRepositoryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$trackRepositoryHash();
@$internal
@override
TrackRepository create() => TrackRepository();
}
String _$trackRepositoryHash() => r'57600f36bc6b3963105e2b6f4b2554dfbc03aa69';
abstract class _$TrackRepository extends $AsyncNotifier<void> {
FutureOr<void> build();
@$mustCallSuper
@override
void runBuild() {
build();
final ref = this.ref as $Ref<AsyncValue<void>, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<void>, void>,
AsyncValue<void>,
Object?,
Object?
>;
element.handleValue(ref, null);
}
}

View File

@@ -0,0 +1,25 @@
import 'package:media_kit/media_kit.dart';
class AudioHandler {
final Player _player;
AudioHandler() : _player = Player() {
// Configure for audio
// _player.setPlaylistMode(PlaylistMode.loop); // Optional
}
Player get player => _player;
Future<void> play() => _player.play();
Future<void> pause() => _player.pause();
Future<void> stop() => _player.stop();
Future<void> seek(Duration position) => _player.seek(position);
Future<void> setSource(String path) async {
await _player.open(Media(path));
}
void dispose() {
_player.dispose();
}
}

View File

@@ -0,0 +1,49 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_media_metadata/flutter_media_metadata.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'metadata_service.g.dart';
class TrackMetadata {
final String? title;
final String? artist;
final String? album;
final Uint8List? artBytes;
TrackMetadata({this.title, this.artist, this.album, this.artBytes});
}
class MetadataService {
Future<TrackMetadata> getMetadata(String path) async {
final file = File(path);
if (!await file.exists()) {
return TrackMetadata();
}
try {
final metadata = await MetadataRetriever.fromFile(file);
return TrackMetadata(
title: metadata.trackName,
artist: metadata.trackArtistNames?.join(
', ',
), // metadata often returns lists
album: metadata.albumName,
artBytes: metadata.albumArt,
);
} catch (e) {
// Fallback or ignore
return TrackMetadata();
}
}
}
@Riverpod(keepAlive: true)
MetadataService metadataService(Ref ref) {
return MetadataService();
}
@riverpod
Future<TrackMetadata> trackMetadata(Ref ref, String path) {
return ref.watch(metadataServiceProvider).getMetadata(path);
}

View File

@@ -0,0 +1,127 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'metadata_service.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(metadataService)
const metadataServiceProvider = MetadataServiceProvider._();
final class MetadataServiceProvider
extends
$FunctionalProvider<MetadataService, MetadataService, MetadataService>
with $Provider<MetadataService> {
const MetadataServiceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'metadataServiceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$metadataServiceHash();
@$internal
@override
$ProviderElement<MetadataService> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
MetadataService create(Ref ref) {
return metadataService(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(MetadataService value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<MetadataService>(value),
);
}
}
String _$metadataServiceHash() => r'62471f009f532ce97bab1ea7e87171ae385592b7';
@ProviderFor(trackMetadata)
const trackMetadataProvider = TrackMetadataFamily._();
final class TrackMetadataProvider
extends
$FunctionalProvider<
AsyncValue<TrackMetadata>,
TrackMetadata,
FutureOr<TrackMetadata>
>
with $FutureModifier<TrackMetadata>, $FutureProvider<TrackMetadata> {
const TrackMetadataProvider._({
required TrackMetadataFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'trackMetadataProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$trackMetadataHash();
@override
String toString() {
return r'trackMetadataProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<TrackMetadata> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<TrackMetadata> create(Ref ref) {
final argument = this.argument as String;
return trackMetadata(ref, argument);
}
@override
bool operator ==(Object other) {
return other is TrackMetadataProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$trackMetadataHash() => r'9833c87e90297f7c9aa952c31f78a73aae78422b';
final class TrackMetadataFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<TrackMetadata>, String> {
const TrackMetadataFamily._()
: super(
retry: null,
name: r'trackMetadataProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
TrackMetadataProvider call(String path) =>
TrackMetadataProvider._(argument: path, from: this);
@override
String toString() => r'trackMetadataProvider';
}

38
lib/main.dart Normal file
View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'ui/shell.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized();
runApp(const ProviderScope(child: MyApp()));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'GroovyBox',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
brightness: Brightness.light,
),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
brightness: Brightness.dark,
),
useMaterial3: true,
),
themeMode: ThemeMode.system,
home: const Shell(),
);
}
}

View File

@@ -0,0 +1,12 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../logic/audio_handler.dart';
part 'audio_provider.g.dart';
@Riverpod(keepAlive: true)
AudioHandler audioHandler(Ref ref) {
final handler = AudioHandler();
ref.onDispose(() => handler.dispose());
return handler;
}

View File

@@ -0,0 +1,51 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'audio_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(audioHandler)
const audioHandlerProvider = AudioHandlerProvider._();
final class AudioHandlerProvider
extends $FunctionalProvider<AudioHandler, AudioHandler, AudioHandler>
with $Provider<AudioHandler> {
const AudioHandlerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'audioHandlerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$audioHandlerHash();
@$internal
@override
$ProviderElement<AudioHandler> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
AudioHandler create(Ref ref) {
return audioHandler(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AudioHandler value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AudioHandler>(value),
);
}
}
String _$audioHandlerHash() => r'd2864a90812b2c615afb327e5a5504558097c945';

View File

@@ -0,0 +1,9 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../data/db.dart';
part 'db_provider.g.dart';
@Riverpod(keepAlive: true)
AppDatabase database(Ref ref) {
return AppDatabase();
}

View File

@@ -0,0 +1,51 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'db_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(database)
const databaseProvider = DatabaseProvider._();
final class DatabaseProvider
extends $FunctionalProvider<AppDatabase, AppDatabase, AppDatabase>
with $Provider<AppDatabase> {
const DatabaseProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'databaseProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$databaseHash();
@$internal
@override
$ProviderElement<AppDatabase> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
AppDatabase create(Ref ref) {
return database(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AppDatabase value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AppDatabase>(value),
);
}
}
String _$databaseHash() => r'e5a1fa0e8ff9aa131f847f28519ec2098e6d0f76';

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),
],
),
),
);
},
);
}
}