💄 Better player UI
This commit is contained in:
@@ -11,6 +11,7 @@ class Tracks extends Table {
|
|||||||
IntColumn get duration => integer().nullable()(); // Duration in milliseconds
|
IntColumn get duration => integer().nullable()(); // Duration in milliseconds
|
||||||
TextColumn get path => text().unique()();
|
TextColumn get path => text().unique()();
|
||||||
TextColumn get artUri => text().nullable()(); // Path to local cover art
|
TextColumn get artUri => text().nullable()(); // Path to local cover art
|
||||||
|
TextColumn get lyrics => text().nullable()(); // JSON formatted lyrics
|
||||||
DateTimeColumn get addedAt => dateTime().withDefault(currentDateAndTime)();
|
DateTimeColumn get addedAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
AppDatabase() : super(_openConnection());
|
AppDatabase() : super(_openConnection());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 3; // Bump version
|
int get schemaVersion => 4; // Bump version for lyrics column
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration {
|
MigrationStrategy get migration {
|
||||||
@@ -49,6 +50,9 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
await m.createTable(playlists);
|
await m.createTable(playlists);
|
||||||
await m.createTable(playlistEntries);
|
await m.createTable(playlistEntries);
|
||||||
}
|
}
|
||||||
|
if (from < 4) {
|
||||||
|
await m.addColumn(tracks, tracks.lyrics);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ class $TracksTable extends Tracks with TableInfo<$TracksTable, Track> {
|
|||||||
type: DriftSqlType.string,
|
type: DriftSqlType.string,
|
||||||
requiredDuringInsert: false,
|
requiredDuringInsert: false,
|
||||||
);
|
);
|
||||||
|
static const VerificationMeta _lyricsMeta = const VerificationMeta('lyrics');
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<String> lyrics = GeneratedColumn<String>(
|
||||||
|
'lyrics',
|
||||||
|
aliasedName,
|
||||||
|
true,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
);
|
||||||
static const VerificationMeta _addedAtMeta = const VerificationMeta(
|
static const VerificationMeta _addedAtMeta = const VerificationMeta(
|
||||||
'addedAt',
|
'addedAt',
|
||||||
);
|
);
|
||||||
@@ -99,6 +108,7 @@ class $TracksTable extends Tracks with TableInfo<$TracksTable, Track> {
|
|||||||
duration,
|
duration,
|
||||||
path,
|
path,
|
||||||
artUri,
|
artUri,
|
||||||
|
lyrics,
|
||||||
addedAt,
|
addedAt,
|
||||||
];
|
];
|
||||||
@override
|
@override
|
||||||
@@ -156,6 +166,12 @@ class $TracksTable extends Tracks with TableInfo<$TracksTable, Track> {
|
|||||||
artUri.isAcceptableOrUnknown(data['art_uri']!, _artUriMeta),
|
artUri.isAcceptableOrUnknown(data['art_uri']!, _artUriMeta),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (data.containsKey('lyrics')) {
|
||||||
|
context.handle(
|
||||||
|
_lyricsMeta,
|
||||||
|
lyrics.isAcceptableOrUnknown(data['lyrics']!, _lyricsMeta),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (data.containsKey('added_at')) {
|
if (data.containsKey('added_at')) {
|
||||||
context.handle(
|
context.handle(
|
||||||
_addedAtMeta,
|
_addedAtMeta,
|
||||||
@@ -199,6 +215,10 @@ class $TracksTable extends Tracks with TableInfo<$TracksTable, Track> {
|
|||||||
DriftSqlType.string,
|
DriftSqlType.string,
|
||||||
data['${effectivePrefix}art_uri'],
|
data['${effectivePrefix}art_uri'],
|
||||||
),
|
),
|
||||||
|
lyrics: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}lyrics'],
|
||||||
|
),
|
||||||
addedAt: attachedDatabase.typeMapping.read(
|
addedAt: attachedDatabase.typeMapping.read(
|
||||||
DriftSqlType.dateTime,
|
DriftSqlType.dateTime,
|
||||||
data['${effectivePrefix}added_at'],
|
data['${effectivePrefix}added_at'],
|
||||||
@@ -220,6 +240,7 @@ class Track extends DataClass implements Insertable<Track> {
|
|||||||
final int? duration;
|
final int? duration;
|
||||||
final String path;
|
final String path;
|
||||||
final String? artUri;
|
final String? artUri;
|
||||||
|
final String? lyrics;
|
||||||
final DateTime addedAt;
|
final DateTime addedAt;
|
||||||
const Track({
|
const Track({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -229,6 +250,7 @@ class Track extends DataClass implements Insertable<Track> {
|
|||||||
this.duration,
|
this.duration,
|
||||||
required this.path,
|
required this.path,
|
||||||
this.artUri,
|
this.artUri,
|
||||||
|
this.lyrics,
|
||||||
required this.addedAt,
|
required this.addedAt,
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
@@ -249,6 +271,9 @@ class Track extends DataClass implements Insertable<Track> {
|
|||||||
if (!nullToAbsent || artUri != null) {
|
if (!nullToAbsent || artUri != null) {
|
||||||
map['art_uri'] = Variable<String>(artUri);
|
map['art_uri'] = Variable<String>(artUri);
|
||||||
}
|
}
|
||||||
|
if (!nullToAbsent || lyrics != null) {
|
||||||
|
map['lyrics'] = Variable<String>(lyrics);
|
||||||
|
}
|
||||||
map['added_at'] = Variable<DateTime>(addedAt);
|
map['added_at'] = Variable<DateTime>(addedAt);
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
@@ -270,6 +295,9 @@ class Track extends DataClass implements Insertable<Track> {
|
|||||||
artUri: artUri == null && nullToAbsent
|
artUri: artUri == null && nullToAbsent
|
||||||
? const Value.absent()
|
? const Value.absent()
|
||||||
: Value(artUri),
|
: Value(artUri),
|
||||||
|
lyrics: lyrics == null && nullToAbsent
|
||||||
|
? const Value.absent()
|
||||||
|
: Value(lyrics),
|
||||||
addedAt: Value(addedAt),
|
addedAt: Value(addedAt),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -287,6 +315,7 @@ class Track extends DataClass implements Insertable<Track> {
|
|||||||
duration: serializer.fromJson<int?>(json['duration']),
|
duration: serializer.fromJson<int?>(json['duration']),
|
||||||
path: serializer.fromJson<String>(json['path']),
|
path: serializer.fromJson<String>(json['path']),
|
||||||
artUri: serializer.fromJson<String?>(json['artUri']),
|
artUri: serializer.fromJson<String?>(json['artUri']),
|
||||||
|
lyrics: serializer.fromJson<String?>(json['lyrics']),
|
||||||
addedAt: serializer.fromJson<DateTime>(json['addedAt']),
|
addedAt: serializer.fromJson<DateTime>(json['addedAt']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -301,6 +330,7 @@ class Track extends DataClass implements Insertable<Track> {
|
|||||||
'duration': serializer.toJson<int?>(duration),
|
'duration': serializer.toJson<int?>(duration),
|
||||||
'path': serializer.toJson<String>(path),
|
'path': serializer.toJson<String>(path),
|
||||||
'artUri': serializer.toJson<String?>(artUri),
|
'artUri': serializer.toJson<String?>(artUri),
|
||||||
|
'lyrics': serializer.toJson<String?>(lyrics),
|
||||||
'addedAt': serializer.toJson<DateTime>(addedAt),
|
'addedAt': serializer.toJson<DateTime>(addedAt),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -313,6 +343,7 @@ class Track extends DataClass implements Insertable<Track> {
|
|||||||
Value<int?> duration = const Value.absent(),
|
Value<int?> duration = const Value.absent(),
|
||||||
String? path,
|
String? path,
|
||||||
Value<String?> artUri = const Value.absent(),
|
Value<String?> artUri = const Value.absent(),
|
||||||
|
Value<String?> lyrics = const Value.absent(),
|
||||||
DateTime? addedAt,
|
DateTime? addedAt,
|
||||||
}) => Track(
|
}) => Track(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -322,6 +353,7 @@ class Track extends DataClass implements Insertable<Track> {
|
|||||||
duration: duration.present ? duration.value : this.duration,
|
duration: duration.present ? duration.value : this.duration,
|
||||||
path: path ?? this.path,
|
path: path ?? this.path,
|
||||||
artUri: artUri.present ? artUri.value : this.artUri,
|
artUri: artUri.present ? artUri.value : this.artUri,
|
||||||
|
lyrics: lyrics.present ? lyrics.value : this.lyrics,
|
||||||
addedAt: addedAt ?? this.addedAt,
|
addedAt: addedAt ?? this.addedAt,
|
||||||
);
|
);
|
||||||
Track copyWithCompanion(TracksCompanion data) {
|
Track copyWithCompanion(TracksCompanion data) {
|
||||||
@@ -333,6 +365,7 @@ class Track extends DataClass implements Insertable<Track> {
|
|||||||
duration: data.duration.present ? data.duration.value : this.duration,
|
duration: data.duration.present ? data.duration.value : this.duration,
|
||||||
path: data.path.present ? data.path.value : this.path,
|
path: data.path.present ? data.path.value : this.path,
|
||||||
artUri: data.artUri.present ? data.artUri.value : this.artUri,
|
artUri: data.artUri.present ? data.artUri.value : this.artUri,
|
||||||
|
lyrics: data.lyrics.present ? data.lyrics.value : this.lyrics,
|
||||||
addedAt: data.addedAt.present ? data.addedAt.value : this.addedAt,
|
addedAt: data.addedAt.present ? data.addedAt.value : this.addedAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -347,14 +380,24 @@ class Track extends DataClass implements Insertable<Track> {
|
|||||||
..write('duration: $duration, ')
|
..write('duration: $duration, ')
|
||||||
..write('path: $path, ')
|
..write('path: $path, ')
|
||||||
..write('artUri: $artUri, ')
|
..write('artUri: $artUri, ')
|
||||||
|
..write('lyrics: $lyrics, ')
|
||||||
..write('addedAt: $addedAt')
|
..write('addedAt: $addedAt')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode => Object.hash(
|
||||||
Object.hash(id, title, artist, album, duration, path, artUri, addedAt);
|
id,
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
album,
|
||||||
|
duration,
|
||||||
|
path,
|
||||||
|
artUri,
|
||||||
|
lyrics,
|
||||||
|
addedAt,
|
||||||
|
);
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
identical(this, other) ||
|
identical(this, other) ||
|
||||||
@@ -366,6 +409,7 @@ class Track extends DataClass implements Insertable<Track> {
|
|||||||
other.duration == this.duration &&
|
other.duration == this.duration &&
|
||||||
other.path == this.path &&
|
other.path == this.path &&
|
||||||
other.artUri == this.artUri &&
|
other.artUri == this.artUri &&
|
||||||
|
other.lyrics == this.lyrics &&
|
||||||
other.addedAt == this.addedAt);
|
other.addedAt == this.addedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,6 +421,7 @@ class TracksCompanion extends UpdateCompanion<Track> {
|
|||||||
final Value<int?> duration;
|
final Value<int?> duration;
|
||||||
final Value<String> path;
|
final Value<String> path;
|
||||||
final Value<String?> artUri;
|
final Value<String?> artUri;
|
||||||
|
final Value<String?> lyrics;
|
||||||
final Value<DateTime> addedAt;
|
final Value<DateTime> addedAt;
|
||||||
const TracksCompanion({
|
const TracksCompanion({
|
||||||
this.id = const Value.absent(),
|
this.id = const Value.absent(),
|
||||||
@@ -386,6 +431,7 @@ class TracksCompanion extends UpdateCompanion<Track> {
|
|||||||
this.duration = const Value.absent(),
|
this.duration = const Value.absent(),
|
||||||
this.path = const Value.absent(),
|
this.path = const Value.absent(),
|
||||||
this.artUri = const Value.absent(),
|
this.artUri = const Value.absent(),
|
||||||
|
this.lyrics = const Value.absent(),
|
||||||
this.addedAt = const Value.absent(),
|
this.addedAt = const Value.absent(),
|
||||||
});
|
});
|
||||||
TracksCompanion.insert({
|
TracksCompanion.insert({
|
||||||
@@ -396,6 +442,7 @@ class TracksCompanion extends UpdateCompanion<Track> {
|
|||||||
this.duration = const Value.absent(),
|
this.duration = const Value.absent(),
|
||||||
required String path,
|
required String path,
|
||||||
this.artUri = const Value.absent(),
|
this.artUri = const Value.absent(),
|
||||||
|
this.lyrics = const Value.absent(),
|
||||||
this.addedAt = const Value.absent(),
|
this.addedAt = const Value.absent(),
|
||||||
}) : title = Value(title),
|
}) : title = Value(title),
|
||||||
path = Value(path);
|
path = Value(path);
|
||||||
@@ -407,6 +454,7 @@ class TracksCompanion extends UpdateCompanion<Track> {
|
|||||||
Expression<int>? duration,
|
Expression<int>? duration,
|
||||||
Expression<String>? path,
|
Expression<String>? path,
|
||||||
Expression<String>? artUri,
|
Expression<String>? artUri,
|
||||||
|
Expression<String>? lyrics,
|
||||||
Expression<DateTime>? addedAt,
|
Expression<DateTime>? addedAt,
|
||||||
}) {
|
}) {
|
||||||
return RawValuesInsertable({
|
return RawValuesInsertable({
|
||||||
@@ -417,6 +465,7 @@ class TracksCompanion extends UpdateCompanion<Track> {
|
|||||||
if (duration != null) 'duration': duration,
|
if (duration != null) 'duration': duration,
|
||||||
if (path != null) 'path': path,
|
if (path != null) 'path': path,
|
||||||
if (artUri != null) 'art_uri': artUri,
|
if (artUri != null) 'art_uri': artUri,
|
||||||
|
if (lyrics != null) 'lyrics': lyrics,
|
||||||
if (addedAt != null) 'added_at': addedAt,
|
if (addedAt != null) 'added_at': addedAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -429,6 +478,7 @@ class TracksCompanion extends UpdateCompanion<Track> {
|
|||||||
Value<int?>? duration,
|
Value<int?>? duration,
|
||||||
Value<String>? path,
|
Value<String>? path,
|
||||||
Value<String?>? artUri,
|
Value<String?>? artUri,
|
||||||
|
Value<String?>? lyrics,
|
||||||
Value<DateTime>? addedAt,
|
Value<DateTime>? addedAt,
|
||||||
}) {
|
}) {
|
||||||
return TracksCompanion(
|
return TracksCompanion(
|
||||||
@@ -439,6 +489,7 @@ class TracksCompanion extends UpdateCompanion<Track> {
|
|||||||
duration: duration ?? this.duration,
|
duration: duration ?? this.duration,
|
||||||
path: path ?? this.path,
|
path: path ?? this.path,
|
||||||
artUri: artUri ?? this.artUri,
|
artUri: artUri ?? this.artUri,
|
||||||
|
lyrics: lyrics ?? this.lyrics,
|
||||||
addedAt: addedAt ?? this.addedAt,
|
addedAt: addedAt ?? this.addedAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -467,6 +518,9 @@ class TracksCompanion extends UpdateCompanion<Track> {
|
|||||||
if (artUri.present) {
|
if (artUri.present) {
|
||||||
map['art_uri'] = Variable<String>(artUri.value);
|
map['art_uri'] = Variable<String>(artUri.value);
|
||||||
}
|
}
|
||||||
|
if (lyrics.present) {
|
||||||
|
map['lyrics'] = Variable<String>(lyrics.value);
|
||||||
|
}
|
||||||
if (addedAt.present) {
|
if (addedAt.present) {
|
||||||
map['added_at'] = Variable<DateTime>(addedAt.value);
|
map['added_at'] = Variable<DateTime>(addedAt.value);
|
||||||
}
|
}
|
||||||
@@ -483,6 +537,7 @@ class TracksCompanion extends UpdateCompanion<Track> {
|
|||||||
..write('duration: $duration, ')
|
..write('duration: $duration, ')
|
||||||
..write('path: $path, ')
|
..write('path: $path, ')
|
||||||
..write('artUri: $artUri, ')
|
..write('artUri: $artUri, ')
|
||||||
|
..write('lyrics: $lyrics, ')
|
||||||
..write('addedAt: $addedAt')
|
..write('addedAt: $addedAt')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
@@ -1079,6 +1134,7 @@ typedef $$TracksTableCreateCompanionBuilder =
|
|||||||
Value<int?> duration,
|
Value<int?> duration,
|
||||||
required String path,
|
required String path,
|
||||||
Value<String?> artUri,
|
Value<String?> artUri,
|
||||||
|
Value<String?> lyrics,
|
||||||
Value<DateTime> addedAt,
|
Value<DateTime> addedAt,
|
||||||
});
|
});
|
||||||
typedef $$TracksTableUpdateCompanionBuilder =
|
typedef $$TracksTableUpdateCompanionBuilder =
|
||||||
@@ -1090,6 +1146,7 @@ typedef $$TracksTableUpdateCompanionBuilder =
|
|||||||
Value<int?> duration,
|
Value<int?> duration,
|
||||||
Value<String> path,
|
Value<String> path,
|
||||||
Value<String?> artUri,
|
Value<String?> artUri,
|
||||||
|
Value<String?> lyrics,
|
||||||
Value<DateTime> addedAt,
|
Value<DateTime> addedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1162,6 +1219,11 @@ class $$TracksTableFilterComposer
|
|||||||
builder: (column) => ColumnFilters(column),
|
builder: (column) => ColumnFilters(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnFilters<String> get lyrics => $composableBuilder(
|
||||||
|
column: $table.lyrics,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
ColumnFilters<DateTime> get addedAt => $composableBuilder(
|
ColumnFilters<DateTime> get addedAt => $composableBuilder(
|
||||||
column: $table.addedAt,
|
column: $table.addedAt,
|
||||||
builder: (column) => ColumnFilters(column),
|
builder: (column) => ColumnFilters(column),
|
||||||
@@ -1237,6 +1299,11 @@ class $$TracksTableOrderingComposer
|
|||||||
builder: (column) => ColumnOrderings(column),
|
builder: (column) => ColumnOrderings(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<String> get lyrics => $composableBuilder(
|
||||||
|
column: $table.lyrics,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
ColumnOrderings<DateTime> get addedAt => $composableBuilder(
|
ColumnOrderings<DateTime> get addedAt => $composableBuilder(
|
||||||
column: $table.addedAt,
|
column: $table.addedAt,
|
||||||
builder: (column) => ColumnOrderings(column),
|
builder: (column) => ColumnOrderings(column),
|
||||||
@@ -1273,6 +1340,9 @@ class $$TracksTableAnnotationComposer
|
|||||||
GeneratedColumn<String> get artUri =>
|
GeneratedColumn<String> get artUri =>
|
||||||
$composableBuilder(column: $table.artUri, builder: (column) => column);
|
$composableBuilder(column: $table.artUri, builder: (column) => column);
|
||||||
|
|
||||||
|
GeneratedColumn<String> get lyrics =>
|
||||||
|
$composableBuilder(column: $table.lyrics, builder: (column) => column);
|
||||||
|
|
||||||
GeneratedColumn<DateTime> get addedAt =>
|
GeneratedColumn<DateTime> get addedAt =>
|
||||||
$composableBuilder(column: $table.addedAt, builder: (column) => column);
|
$composableBuilder(column: $table.addedAt, builder: (column) => column);
|
||||||
|
|
||||||
@@ -1337,6 +1407,7 @@ class $$TracksTableTableManager
|
|||||||
Value<int?> duration = const Value.absent(),
|
Value<int?> duration = const Value.absent(),
|
||||||
Value<String> path = const Value.absent(),
|
Value<String> path = const Value.absent(),
|
||||||
Value<String?> artUri = const Value.absent(),
|
Value<String?> artUri = const Value.absent(),
|
||||||
|
Value<String?> lyrics = const Value.absent(),
|
||||||
Value<DateTime> addedAt = const Value.absent(),
|
Value<DateTime> addedAt = const Value.absent(),
|
||||||
}) => TracksCompanion(
|
}) => TracksCompanion(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -1346,6 +1417,7 @@ class $$TracksTableTableManager
|
|||||||
duration: duration,
|
duration: duration,
|
||||||
path: path,
|
path: path,
|
||||||
artUri: artUri,
|
artUri: artUri,
|
||||||
|
lyrics: lyrics,
|
||||||
addedAt: addedAt,
|
addedAt: addedAt,
|
||||||
),
|
),
|
||||||
createCompanionCallback:
|
createCompanionCallback:
|
||||||
@@ -1357,6 +1429,7 @@ class $$TracksTableTableManager
|
|||||||
Value<int?> duration = const Value.absent(),
|
Value<int?> duration = const Value.absent(),
|
||||||
required String path,
|
required String path,
|
||||||
Value<String?> artUri = const Value.absent(),
|
Value<String?> artUri = const Value.absent(),
|
||||||
|
Value<String?> lyrics = const Value.absent(),
|
||||||
Value<DateTime> addedAt = const Value.absent(),
|
Value<DateTime> addedAt = const Value.absent(),
|
||||||
}) => TracksCompanion.insert(
|
}) => TracksCompanion.insert(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -1366,6 +1439,7 @@ class $$TracksTableTableManager
|
|||||||
duration: duration,
|
duration: duration,
|
||||||
path: path,
|
path: path,
|
||||||
artUri: artUri,
|
artUri: artUri,
|
||||||
|
lyrics: lyrics,
|
||||||
addedAt: addedAt,
|
addedAt: addedAt,
|
||||||
),
|
),
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ final class PlaylistRepositoryProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$playlistRepositoryHash() =>
|
String _$playlistRepositoryHash() =>
|
||||||
r'9a76fa2443bfb810b75b26adaf6225de48049a3a';
|
r'614d837f9438d2454778edb4ff60b046418490b8';
|
||||||
|
|
||||||
abstract class _$PlaylistRepository extends $AsyncNotifier<void> {
|
abstract class _$PlaylistRepository extends $AsyncNotifier<void> {
|
||||||
FutureOr<void> build();
|
FutureOr<void> build();
|
||||||
|
|||||||
@@ -128,4 +128,26 @@ class TrackRepository extends _$TrackRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update lyrics for a track.
|
||||||
|
Future<void> updateLyrics(int trackId, String? lyricsJson) async {
|
||||||
|
final db = ref.read(databaseProvider);
|
||||||
|
await (db.update(db.tracks)..where((t) => t.id.equals(trackId))).write(
|
||||||
|
TracksCompanion(lyrics: Value(lyricsJson)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a single track by ID.
|
||||||
|
Future<Track?> getTrack(int trackId) async {
|
||||||
|
final db = ref.read(databaseProvider);
|
||||||
|
return (db.select(
|
||||||
|
db.tracks,
|
||||||
|
)..where((t) => t.id.equals(trackId))).getSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all tracks for batch matching.
|
||||||
|
Future<List<Track>> getAllTracks() async {
|
||||||
|
final db = ref.read(databaseProvider);
|
||||||
|
return db.select(db.tracks).get();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ final class TrackRepositoryProvider
|
|||||||
TrackRepository create() => TrackRepository();
|
TrackRepository create() => TrackRepository();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$trackRepositoryHash() => r'57600f36bc6b3963105e2b6f4b2554dfbc03aa69';
|
String _$trackRepositoryHash() => r'ad77006c472739d9d5067d394d6c5a3437535a11';
|
||||||
|
|
||||||
abstract class _$TrackRepository extends $AsyncNotifier<void> {
|
abstract class _$TrackRepository extends $AsyncNotifier<void> {
|
||||||
FutureOr<void> build();
|
FutureOr<void> build();
|
||||||
|
|||||||
155
lib/logic/lyrics_parser.dart
Normal file
155
lib/logic/lyrics_parser.dart
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
/// Represents a single line of lyrics with optional timing.
|
||||||
|
class LyricsLine {
|
||||||
|
final int? timeMs; // Time in milliseconds, null for plaintext
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
LyricsLine({this.timeMs, required this.text});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {'time': timeMs, 'text': text};
|
||||||
|
|
||||||
|
factory LyricsLine.fromJson(Map<String, dynamic> json) =>
|
||||||
|
LyricsLine(timeMs: json['time'] as int?, text: json['text'] as String);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents parsed lyrics data.
|
||||||
|
class LyricsData {
|
||||||
|
final String type; // 'timed' or 'plain'
|
||||||
|
final List<LyricsLine> lines;
|
||||||
|
|
||||||
|
LyricsData({required this.type, required this.lines});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'type': type,
|
||||||
|
'lines': lines.map((l) => l.toJson()).toList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory LyricsData.fromJson(Map<String, dynamic> json) => LyricsData(
|
||||||
|
type: json['type'] as String,
|
||||||
|
lines: (json['lines'] as List)
|
||||||
|
.map((l) => LyricsLine.fromJson(l as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
String toJsonString() => jsonEncode(toJson());
|
||||||
|
|
||||||
|
static LyricsData fromJsonString(String json) =>
|
||||||
|
LyricsData.fromJson(jsonDecode(json) as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parser for various lyrics file formats.
|
||||||
|
class LyricsParser {
|
||||||
|
/// Parse LRC format lyrics.
|
||||||
|
/// Format: [mm:ss.xx] Lyrics text
|
||||||
|
static LyricsData parseLrc(String content) {
|
||||||
|
final lines = <LyricsLine>[];
|
||||||
|
final regex = RegExp(r'\[(\d+):(\d+)\.?(\d+)?\](.*)');
|
||||||
|
|
||||||
|
for (final line in content.split('\n')) {
|
||||||
|
final match = regex.firstMatch(line.trim());
|
||||||
|
if (match != null) {
|
||||||
|
final minutes = int.parse(match.group(1)!);
|
||||||
|
final seconds = int.parse(match.group(2)!);
|
||||||
|
final centiseconds = int.tryParse(match.group(3) ?? '0') ?? 0;
|
||||||
|
final text = match.group(4)?.trim() ?? '';
|
||||||
|
|
||||||
|
// Convert to milliseconds
|
||||||
|
final timeMs =
|
||||||
|
(minutes * 60 * 1000) +
|
||||||
|
(seconds * 1000) +
|
||||||
|
(centiseconds * 10); // centiseconds to ms
|
||||||
|
|
||||||
|
if (text.isNotEmpty) {
|
||||||
|
lines.add(LyricsLine(timeMs: timeMs, text: text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by time
|
||||||
|
lines.sort((a, b) => (a.timeMs ?? 0).compareTo(b.timeMs ?? 0));
|
||||||
|
|
||||||
|
return LyricsData(type: 'timed', lines: lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse SRT (SubRip) format.
|
||||||
|
/// Format:
|
||||||
|
/// 1
|
||||||
|
/// 00:00:12,500 --> 00:00:15,000
|
||||||
|
/// Lyrics text
|
||||||
|
static LyricsData parseSrt(String content) {
|
||||||
|
final lines = <LyricsLine>[];
|
||||||
|
final blocks = content.split(RegExp(r'\n\s*\n'));
|
||||||
|
final timeRegex = RegExp(
|
||||||
|
r'(\d+):(\d+):(\d+)[,.](\d+)\s*-->\s*\d+:\d+:\d+[,.]?\d*',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final block in blocks) {
|
||||||
|
final blockLines = block.trim().split('\n');
|
||||||
|
if (blockLines.length >= 2) {
|
||||||
|
// Find timestamp line
|
||||||
|
for (int i = 0; i < blockLines.length; i++) {
|
||||||
|
final match = timeRegex.firstMatch(blockLines[i]);
|
||||||
|
if (match != null) {
|
||||||
|
final hours = int.parse(match.group(1)!);
|
||||||
|
final minutes = int.parse(match.group(2)!);
|
||||||
|
final seconds = int.parse(match.group(3)!);
|
||||||
|
final millis = int.parse(match.group(4)!.padRight(3, '0'));
|
||||||
|
|
||||||
|
final timeMs =
|
||||||
|
(hours * 3600 * 1000) +
|
||||||
|
(minutes * 60 * 1000) +
|
||||||
|
(seconds * 1000) +
|
||||||
|
millis;
|
||||||
|
|
||||||
|
// Text is everything after the timestamp line
|
||||||
|
final text = blockLines.sublist(i + 1).join(' ').trim();
|
||||||
|
if (text.isNotEmpty) {
|
||||||
|
lines.add(LyricsLine(timeMs: timeMs, text: text));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by time
|
||||||
|
lines.sort((a, b) => (a.timeMs ?? 0).compareTo(b.timeMs ?? 0));
|
||||||
|
|
||||||
|
return LyricsData(type: 'timed', lines: lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse plaintext lyrics (no timing).
|
||||||
|
static LyricsData parsePlaintext(String content) {
|
||||||
|
final lines = content
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.where((l) => l.isNotEmpty)
|
||||||
|
.map((l) => LyricsLine(text: l))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return LyricsData(type: 'plain', lines: lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auto-detect format and parse.
|
||||||
|
static LyricsData parse(String content, String filename) {
|
||||||
|
final lowerFilename = filename.toLowerCase();
|
||||||
|
|
||||||
|
if (lowerFilename.endsWith('.lrc')) {
|
||||||
|
return parseLrc(content);
|
||||||
|
} else if (lowerFilename.endsWith('.srt')) {
|
||||||
|
return parseSrt(content);
|
||||||
|
} else {
|
||||||
|
// Check if content looks like LRC
|
||||||
|
if (RegExp(r'\[\d+:\d+').hasMatch(content)) {
|
||||||
|
return parseLrc(content);
|
||||||
|
}
|
||||||
|
// Check if content looks like SRT
|
||||||
|
if (RegExp(r'\d+:\d+:\d+[,.]').hasMatch(content)) {
|
||||||
|
return parseSrt(content);
|
||||||
|
}
|
||||||
|
// Default to plaintext
|
||||||
|
return parsePlaintext(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import '../../data/track_repository.dart';
|
|||||||
import '../../providers/audio_provider.dart';
|
import '../../providers/audio_provider.dart';
|
||||||
import '../../data/playlist_repository.dart';
|
import '../../data/playlist_repository.dart';
|
||||||
import '../../data/db.dart';
|
import '../../data/db.dart';
|
||||||
|
import '../../logic/lyrics_parser.dart';
|
||||||
import '../tabs/albums_tab.dart';
|
import '../tabs/albums_tab.dart';
|
||||||
import '../tabs/playlists_tab.dart';
|
import '../tabs/playlists_tab.dart';
|
||||||
|
|
||||||
@@ -109,6 +110,11 @@ class LibraryScreen extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.lyrics_outlined),
|
||||||
|
tooltip: 'Batch Import Lyrics',
|
||||||
|
onPressed: () => _batchImportLyrics(context, ref),
|
||||||
|
),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -130,6 +136,9 @@ class LibraryScreen extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: 72 + MediaQuery.paddingOf(context).bottom,
|
||||||
|
),
|
||||||
itemCount: tracks.length,
|
itemCount: tracks.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final track = tracks[index];
|
final track = tracks[index];
|
||||||
@@ -295,6 +304,14 @@ class LibraryScreen extends HookConsumerWidget {
|
|||||||
_showEditDialog(context, ref, track);
|
_showEditDialog(context, ref, track);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.lyrics_outlined),
|
||||||
|
title: const Text('Import Lyrics'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_importLyricsForTrack(context, ref, track);
|
||||||
|
},
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.delete, color: Colors.red),
|
leading: const Icon(Icons.delete, color: Colors.red),
|
||||||
title: const Text(
|
title: const Text(
|
||||||
@@ -557,4 +574,92 @@ class LibraryScreen extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _importLyricsForTrack(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
Track track,
|
||||||
|
) async {
|
||||||
|
final result = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: ['lrc', 'srt', 'txt'],
|
||||||
|
allowMultiple: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result != null && result.files.isNotEmpty) {
|
||||||
|
final file = File(result.files.first.path!);
|
||||||
|
final content = await file.readAsString();
|
||||||
|
final filename = result.files.first.name;
|
||||||
|
|
||||||
|
final lyricsData = LyricsParser.parse(content, filename);
|
||||||
|
final lyricsJson = lyricsData.toJsonString();
|
||||||
|
|
||||||
|
await ref
|
||||||
|
.read(trackRepositoryProvider.notifier)
|
||||||
|
.updateLyrics(track.id, lyricsJson);
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Imported ${lyricsData.lines.length} lyrics lines for "${track.title}"',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _batchImportLyrics(BuildContext context, WidgetRef ref) async {
|
||||||
|
final result = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: ['lrc', 'srt', 'txt'],
|
||||||
|
allowMultiple: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result == null || result.files.isEmpty) return;
|
||||||
|
|
||||||
|
final repo = ref.read(trackRepositoryProvider.notifier);
|
||||||
|
final tracks = await repo.getAllTracks();
|
||||||
|
|
||||||
|
int matched = 0;
|
||||||
|
int notMatched = 0;
|
||||||
|
|
||||||
|
for (final pickedFile in result.files) {
|
||||||
|
if (pickedFile.path == null) continue;
|
||||||
|
|
||||||
|
final file = File(pickedFile.path!);
|
||||||
|
final content = await file.readAsString();
|
||||||
|
final filename = pickedFile.name;
|
||||||
|
|
||||||
|
// Get basename without extension for matching
|
||||||
|
final baseName = filename
|
||||||
|
.replaceAll(RegExp(r'\.(lrc|srt|txt)$', caseSensitive: false), '')
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
// Try to find a matching track by title
|
||||||
|
final matchingTrack = tracks.where((t) {
|
||||||
|
final trackTitle = t.title.toLowerCase();
|
||||||
|
return trackTitle == baseName ||
|
||||||
|
trackTitle.contains(baseName) ||
|
||||||
|
baseName.contains(trackTitle);
|
||||||
|
}).firstOrNull;
|
||||||
|
|
||||||
|
if (matchingTrack != null) {
|
||||||
|
final lyricsData = LyricsParser.parse(content, filename);
|
||||||
|
await repo.updateLyrics(matchingTrack.id, lyricsData.toJsonString());
|
||||||
|
matched++;
|
||||||
|
} else {
|
||||||
|
notMatched++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Batch import complete: $matched matched, $notMatched not matched',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
|
import 'package:super_sliver_list/super_sliver_list.dart';
|
||||||
|
|
||||||
import '../../providers/audio_provider.dart';
|
import '../../providers/audio_provider.dart';
|
||||||
|
import '../../providers/db_provider.dart';
|
||||||
import '../../logic/metadata_service.dart';
|
import '../../logic/metadata_service.dart';
|
||||||
|
import '../../logic/lyrics_parser.dart';
|
||||||
|
import '../../data/db.dart' as db;
|
||||||
import '../widgets/mini_player.dart';
|
import '../widgets/mini_player.dart';
|
||||||
|
|
||||||
class PlayerScreen extends HookConsumerWidget {
|
class PlayerScreen extends HookConsumerWidget {
|
||||||
@@ -15,16 +19,14 @@ class PlayerScreen extends HookConsumerWidget {
|
|||||||
final audioHandler = ref.watch(audioHandlerProvider);
|
final audioHandler = ref.watch(audioHandlerProvider);
|
||||||
final player = audioHandler.player;
|
final player = audioHandler.player;
|
||||||
|
|
||||||
|
final tabController = useTabController(initialLength: 2);
|
||||||
|
final isMobile = MediaQuery.sizeOf(context).width <= 640;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
body: Stack(
|
||||||
title: const Text('Now Playing'),
|
children: [
|
||||||
centerTitle: true,
|
// Main content (StreamBuilder)
|
||||||
leading: IconButton(
|
StreamBuilder<Playlist>(
|
||||||
icon: const Icon(Icons.keyboard_arrow_down),
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: StreamBuilder<Playlist>(
|
|
||||||
stream: player.stream.playlist,
|
stream: player.stream.playlist,
|
||||||
initialData: player.state.playlist,
|
initialData: player.state.playlist,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
@@ -38,60 +40,91 @@ class PlayerScreen extends HookConsumerWidget {
|
|||||||
|
|
||||||
final metadataAsync = ref.watch(trackMetadataProvider(path));
|
final metadataAsync = ref.watch(trackMetadataProvider(path));
|
||||||
|
|
||||||
return LayoutBuilder(
|
return Builder(
|
||||||
builder: (context, constraints) {
|
builder: (context) {
|
||||||
if (constraints.maxWidth < 600) {
|
if (isMobile) {
|
||||||
return _MobileLayout(
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
top: MediaQuery.of(context).padding.top + 48,
|
||||||
|
),
|
||||||
|
child: _MobileLayout(
|
||||||
player: player,
|
player: player,
|
||||||
|
tabController: tabController,
|
||||||
metadataAsync: metadataAsync,
|
metadataAsync: metadataAsync,
|
||||||
media: media,
|
media: media,
|
||||||
|
trackPath: path,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return _DesktopLayout(
|
return _DesktopLayout(
|
||||||
player: player,
|
player: player,
|
||||||
metadataAsync: metadataAsync,
|
metadataAsync: metadataAsync,
|
||||||
media: media,
|
media: media,
|
||||||
|
trackPath: path,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
// IconButton
|
||||||
|
Positioned(
|
||||||
|
top: MediaQuery.of(context).padding.top + 8,
|
||||||
|
left: 8,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.keyboard_arrow_down),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// TabBar (if mobile)
|
||||||
|
if (isMobile)
|
||||||
|
Positioned(
|
||||||
|
top: MediaQuery.of(context).padding.top + 8,
|
||||||
|
left: 50,
|
||||||
|
right: 50,
|
||||||
|
child: TabBar(
|
||||||
|
controller: tabController,
|
||||||
|
tabAlignment: TabAlignment.fill,
|
||||||
|
tabs: const [
|
||||||
|
Tab(text: 'Cover'),
|
||||||
|
Tab(text: 'Lyrics'),
|
||||||
|
],
|
||||||
|
dividerHeight: 0,
|
||||||
|
indicatorColor: Colors.transparent,
|
||||||
|
overlayColor: WidgetStatePropertyAll(Colors.transparent),
|
||||||
|
splashFactory: NoSplash.splashFactory,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MobileLayout extends StatelessWidget {
|
class _MobileLayout extends StatelessWidget {
|
||||||
final Player player;
|
final Player player;
|
||||||
|
final TabController tabController;
|
||||||
final AsyncValue<TrackMetadata> metadataAsync;
|
final AsyncValue<TrackMetadata> metadataAsync;
|
||||||
final Media media;
|
final Media media;
|
||||||
|
final String trackPath;
|
||||||
|
|
||||||
const _MobileLayout({
|
const _MobileLayout({
|
||||||
required this.player,
|
required this.player,
|
||||||
|
required this.tabController,
|
||||||
required this.metadataAsync,
|
required this.metadataAsync,
|
||||||
required this.media,
|
required this.media,
|
||||||
|
required this.trackPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DefaultTabController(
|
return TabBarView(
|
||||||
length: 2,
|
controller: tabController,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
|
||||||
const TabBar(
|
|
||||||
tabs: [
|
|
||||||
Tab(text: 'Cover'),
|
|
||||||
Tab(text: 'Lyrics'),
|
|
||||||
],
|
|
||||||
dividerColor: Colors.transparent,
|
|
||||||
splashFactory: NoSplash.splashFactory,
|
|
||||||
overlayColor: WidgetStatePropertyAll(Colors.transparent),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: TabBarView(
|
|
||||||
children: [
|
|
||||||
// Cover Art Tab with Full Controls
|
|
||||||
Column(
|
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -109,27 +142,20 @@ class _MobileLayout extends StatelessWidget {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
// Lyrics Tab with Mini Player
|
// Lyrics Tab with Mini Player
|
||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
const Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(16.0),
|
padding: EdgeInsets.all(16.0),
|
||||||
child: _PlayerLyrics(),
|
child: _PlayerLyrics(trackPath: trackPath, player: player),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
MiniPlayer(enableTapToOpen: false),
|
MiniPlayer(enableTapToOpen: false),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
// Tab Indicators (Overlay style or bottom? Standard is usually separate or top. Keeping bottom for consistency with previous, but maybe cleaner at top? User didn't specify position, just content. Let's keep indicators at bottom of screen but actually, if controls are in tabs, indicators might overlap? Moving indicators to top standard position or just removing them if swiping is enough? Let's keep them at the top of the content area for clarity, or overlay. Actually, previous layout had indicators below TabBarView but above controls. Now controls are IN TabBarView. Let's put TabBar at the TOP or BOTTOM of the screen. Top is standard Android/iOS for sub-views. Let's try TOP.)
|
|
||||||
// Actually, let's put TabBar at the very bottom, or top. Let's stick to top as standard.)
|
|
||||||
// Wait, the previous code had them between tabs and controls.
|
|
||||||
// Let's place TabBar at the top of the screen (below AppBar) for this layout.
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,11 +164,13 @@ class _DesktopLayout extends StatelessWidget {
|
|||||||
final Player player;
|
final Player player;
|
||||||
final AsyncValue<TrackMetadata> metadataAsync;
|
final AsyncValue<TrackMetadata> metadataAsync;
|
||||||
final Media media;
|
final Media media;
|
||||||
|
final String trackPath;
|
||||||
|
|
||||||
const _DesktopLayout({
|
const _DesktopLayout({
|
||||||
required this.player,
|
required this.player,
|
||||||
required this.metadataAsync,
|
required this.metadataAsync,
|
||||||
required this.media,
|
required this.media,
|
||||||
|
required this.trackPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -152,6 +180,9 @@ class _DesktopLayout extends StatelessWidget {
|
|||||||
// Left Side: Cover + Controls
|
// Left Side: Cover + Controls
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 480),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
@@ -163,7 +194,9 @@ class _DesktopLayout extends StatelessWidget {
|
|||||||
constraints: const BoxConstraints(maxWidth: 400),
|
constraints: const BoxConstraints(maxWidth: 400),
|
||||||
child: AspectRatio(
|
child: AspectRatio(
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
child: _PlayerCoverArt(metadataAsync: metadataAsync),
|
child: _PlayerCoverArt(
|
||||||
|
metadataAsync: metadataAsync,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -178,10 +211,15 @@ class _DesktopLayout extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
// Right Side: Lyrics
|
// Right Side: Lyrics
|
||||||
const Expanded(
|
Expanded(
|
||||||
flex: 1,
|
flex: 1,
|
||||||
child: Padding(padding: EdgeInsets.all(32.0), child: _PlayerLyrics()),
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(32.0),
|
||||||
|
child: _PlayerLyrics(trackPath: trackPath, player: player),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -221,7 +259,7 @@ class _PlayerCoverArt extends StatelessWidget {
|
|||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (_, __) => Container(
|
error: (_, _) => Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey[800],
|
color: Colors.grey[800],
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
@@ -234,25 +272,159 @@ class _PlayerCoverArt extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PlayerLyrics extends StatelessWidget {
|
class _PlayerLyrics extends HookConsumerWidget {
|
||||||
const _PlayerLyrics();
|
final String? trackPath;
|
||||||
|
final Player player;
|
||||||
|
|
||||||
|
const _PlayerLyrics({this.trackPath, required this.player});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return Container(
|
// Watch for track data (including lyrics) by path
|
||||||
decoration: BoxDecoration(
|
final trackAsync = trackPath != null
|
||||||
color: Theme.of(
|
? ref.watch(_trackByPathProvider(trackPath!))
|
||||||
context,
|
: const AsyncValue<db.Track?>.data(null);
|
||||||
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
return trackAsync.when(
|
||||||
),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
child: const Center(
|
error: (e, _) => Center(child: Text('Error: $e')),
|
||||||
|
data: (track) {
|
||||||
|
if (track == null || track.lyrics == null) {
|
||||||
|
return const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No Lyrics Available',
|
'No Lyrics Available',
|
||||||
style: TextStyle(fontStyle: FontStyle.italic),
|
style: TextStyle(fontStyle: FontStyle.italic),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final lyricsData = LyricsData.fromJsonString(track.lyrics!);
|
||||||
|
|
||||||
|
if (lyricsData.type == 'timed') {
|
||||||
|
return _TimedLyricsView(lyrics: lyricsData, player: player);
|
||||||
|
} else {
|
||||||
|
// Plain text lyrics
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: lyricsData.lines.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Text(
|
||||||
|
lyricsData.lines[index].text,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return Center(child: Text('Error parsing lyrics: $e'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider to fetch a single track by path
|
||||||
|
final _trackByPathProvider = FutureProvider.family<db.Track?, String>((
|
||||||
|
ref,
|
||||||
|
trackPath,
|
||||||
|
) async {
|
||||||
|
final database = ref.watch(databaseProvider);
|
||||||
|
return (database.select(
|
||||||
|
database.tracks,
|
||||||
|
)..where((t) => t.path.equals(trackPath))).getSingleOrNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
class _TimedLyricsView extends HookWidget {
|
||||||
|
final LyricsData lyrics;
|
||||||
|
final Player player;
|
||||||
|
|
||||||
|
const _TimedLyricsView({required this.lyrics, required this.player});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final listController = useMemoized(() => ListController(), []);
|
||||||
|
final scrollController = useScrollController();
|
||||||
|
final previousIndex = useState(-1);
|
||||||
|
|
||||||
|
return StreamBuilder<Duration>(
|
||||||
|
stream: player.stream.position,
|
||||||
|
initialData: player.state.position,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final position = snapshot.data ?? Duration.zero;
|
||||||
|
final positionMs = position.inMilliseconds;
|
||||||
|
|
||||||
|
// Find current line index
|
||||||
|
int currentIndex = 0;
|
||||||
|
for (int i = 0; i < lyrics.lines.length; i++) {
|
||||||
|
if ((lyrics.lines[i].timeMs ?? 0) <= positionMs) {
|
||||||
|
currentIndex = i;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-scroll when current line changes
|
||||||
|
if (currentIndex != previousIndex.value) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
previousIndex.value = currentIndex;
|
||||||
|
listController.animateToItem(
|
||||||
|
index: currentIndex,
|
||||||
|
scrollController: scrollController,
|
||||||
|
alignment: 0.5,
|
||||||
|
duration: (_) => const Duration(milliseconds: 300),
|
||||||
|
curve: (_) => Curves.easeOutCubic,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return SuperListView.builder(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
top: 0.25 * MediaQuery.sizeOf(context).height,
|
||||||
|
bottom: 0.25 * MediaQuery.sizeOf(context).height,
|
||||||
|
),
|
||||||
|
listController: listController,
|
||||||
|
controller: scrollController,
|
||||||
|
itemCount: lyrics.lines.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final line = lyrics.lines[index];
|
||||||
|
final isActive = index == currentIndex;
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: () {
|
||||||
|
if (line.timeMs != null) {
|
||||||
|
player.seek(Duration(milliseconds: line.timeMs!));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 8,
|
||||||
|
horizontal: 16,
|
||||||
|
),
|
||||||
|
child: AnimatedDefaultTextStyle(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
|
||||||
|
fontSize: isActive ? 20 : 16,
|
||||||
|
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||||
|
color: isActive
|
||||||
|
? Theme.of(context).colorScheme.primary
|
||||||
|
: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
child: Text(line.text, textAlign: TextAlign.center),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
@@ -36,7 +37,7 @@ class MiniPlayer extends HookConsumerWidget {
|
|||||||
final metadataAsync = ref.watch(trackMetadataProvider(filePath));
|
final metadataAsync = ref.watch(trackMetadataProvider(filePath));
|
||||||
|
|
||||||
Widget content = Container(
|
Widget content = Container(
|
||||||
height: 80, // Increased height for slider
|
height: 72,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
@@ -82,8 +83,10 @@ class MiniPlayer extends HookConsumerWidget {
|
|||||||
thumbShape: const RoundSliderThumbShape(
|
thumbShape: const RoundSliderThumbShape(
|
||||||
enabledThumbRadius: 6,
|
enabledThumbRadius: 6,
|
||||||
),
|
),
|
||||||
|
trackShape: const RectangularSliderTrackShape(),
|
||||||
),
|
),
|
||||||
child: Slider(
|
child: Slider(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
value: currentValue,
|
value: currentValue,
|
||||||
min: 0,
|
min: 0,
|
||||||
max: max > 0 ? max : 1.0,
|
max: max > 0 ? max : 1.0,
|
||||||
@@ -106,9 +109,7 @@ class MiniPlayer extends HookConsumerWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Cover Art (Small)
|
// Cover Art (Small)
|
||||||
Padding(
|
AspectRatio(
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: AspectRatio(
|
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
child: metadataAsync.when(
|
child: metadataAsync.when(
|
||||||
data: (meta) => meta.artBytes != null
|
data: (meta) => meta.artBytes != null
|
||||||
@@ -121,10 +122,10 @@ class MiniPlayer extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
loading: () => Container(color: Colors.grey[800]),
|
loading: () => Container(color: Colors.grey[800]),
|
||||||
error: (_, __) => Container(color: Colors.grey[800]),
|
error: (_, _) => Container(color: Colors.grey[800]),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const Gap(8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
@@ -142,7 +143,7 @@ class MiniPlayer extends HookConsumerWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
loading: () => const Text('Loading...'),
|
loading: () => const Text('Loading...'),
|
||||||
error: (_, __) =>
|
error: (_, _) =>
|
||||||
Text(Uri.parse(media.uri).pathSegments.last),
|
Text(Uri.parse(media.uri).pathSegments.last),
|
||||||
),
|
),
|
||||||
metadataAsync.when(
|
metadataAsync.when(
|
||||||
@@ -153,7 +154,7 @@ class MiniPlayer extends HookConsumerWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
loading: () => const SizedBox.shrink(),
|
loading: () => const SizedBox.shrink(),
|
||||||
error: (_, __) => const SizedBox.shrink(),
|
error: (_, _) => const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -163,13 +164,31 @@ class MiniPlayer extends HookConsumerWidget {
|
|||||||
stream: player.stream.playing,
|
stream: player.stream.playing,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final playing = snapshot.data ?? false;
|
final playing = snapshot.data ?? false;
|
||||||
return IconButton(
|
return Padding(
|
||||||
icon: Icon(playing ? Icons.pause : Icons.play_arrow),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: IconButton.filled(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primaryContainer,
|
||||||
|
icon: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 100),
|
||||||
|
transitionBuilder:
|
||||||
|
(Widget child, Animation<double> animation) {
|
||||||
|
return ScaleTransition(
|
||||||
|
scale: animation,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Icon(
|
||||||
|
playing ? Icons.pause : Icons.play_arrow,
|
||||||
|
key: ValueKey<bool>(playing),
|
||||||
|
),
|
||||||
|
),
|
||||||
onPressed: playing ? player.pause : player.play,
|
onPressed: playing ? player.pause : player.play,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
24
pubspec.lock
24
pubspec.lock
@@ -504,6 +504,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.2"
|
version: "3.0.2"
|
||||||
|
lint:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: lint
|
||||||
|
sha256: "4a539aa34ec5721a2c7574ae2ca0336738ea4adc2a34887d54b7596310b33c85"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.10.0"
|
||||||
lints:
|
lints:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -925,6 +933,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
|
styled_widget:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: styled_widget
|
||||||
|
sha256: "4d439802919b6ccf10d1488798656da8804633b03012682dd1c8ca70a084aa84"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.4.1"
|
||||||
|
super_sliver_list:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: super_sliver_list
|
||||||
|
sha256: b1e1e64d08ce40e459b9bb5d9f8e361617c26b8c9f3bb967760b0f436b6e3f56
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.4.1"
|
||||||
synchronized:
|
synchronized:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ dependencies:
|
|||||||
flutter_media_metadata: ^1.0.0+1
|
flutter_media_metadata: ^1.0.0+1
|
||||||
animations: ^2.1.1
|
animations: ^2.1.1
|
||||||
gap: ^3.0.1
|
gap: ^3.0.1
|
||||||
|
styled_widget: ^0.4.1
|
||||||
|
super_sliver_list: ^0.4.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user