🎉 Initial Commit
This commit is contained in:
59
lib/data/db.dart
Normal file
59
lib/data/db.dart
Normal 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
2089
lib/data/db.g.dart
Normal file
File diff suppressed because it is too large
Load Diff
110
lib/data/playlist_repository.dart
Normal file
110
lib/data/playlist_repository.dart
Normal 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);
|
||||
}
|
||||
56
lib/data/playlist_repository.g.dart
Normal file
56
lib/data/playlist_repository.g.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
131
lib/data/track_repository.dart
Normal file
131
lib/data/track_repository.dart
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
55
lib/data/track_repository.g.dart
Normal file
55
lib/data/track_repository.g.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
25
lib/logic/audio_handler.dart
Normal file
25
lib/logic/audio_handler.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
49
lib/logic/metadata_service.dart
Normal file
49
lib/logic/metadata_service.dart
Normal 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);
|
||||
}
|
||||
127
lib/logic/metadata_service.g.dart
Normal file
127
lib/logic/metadata_service.g.dart
Normal 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
38
lib/main.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
12
lib/providers/audio_provider.dart
Normal file
12
lib/providers/audio_provider.dart
Normal 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;
|
||||
}
|
||||
51
lib/providers/audio_provider.g.dart
Normal file
51
lib/providers/audio_provider.g.dart
Normal 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';
|
||||
9
lib/providers/db_provider.dart
Normal file
9
lib/providers/db_provider.dart
Normal 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();
|
||||
}
|
||||
51
lib/providers/db_provider.g.dart
Normal file
51
lib/providers/db_provider.g.dart
Normal 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';
|
||||
557
lib/ui/screens/library_screen.dart
Normal file
557
lib/ui/screens/library_screen.dart
Normal 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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
240
lib/ui/screens/player_screen.dart
Normal file
240
lib/ui/screens/player_screen.dart
Normal 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
27
lib/ui/shell.dart
Normal 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()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
89
lib/ui/tabs/albums_tab.dart
Normal file
89
lib/ui/tabs/albums_tab.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
82
lib/ui/tabs/playlists_tab.dart
Normal file
82
lib/ui/tabs/playlists_tab.dart
Normal 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}')),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
124
lib/ui/widgets/mini_player.dart
Normal file
124
lib/ui/widgets/mini_player.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user