🎉 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user