🎉 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);
}
}