Initial commit
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:on_audio_query/on_audio_query.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'database.dart';
|
||||
|
||||
const _uuid = Uuid();
|
||||
|
||||
/// Android-Scan über MediaStore (der einzige zuverlässige Weg ab Android 11,
|
||||
/// da Scoped Storage direkten Dateizugriff blockiert). Findet automatisch alle
|
||||
/// Musik auf dem Gerät. Schreibt in dieselbe DB wie der Desktop-Scan,
|
||||
/// UUID-stabil über den Dateipfad.
|
||||
Future<int> scanAndroidMediaStore(
|
||||
MeloDb db, {
|
||||
Directory? coverDir,
|
||||
void Function(int done, int total)? onProgress,
|
||||
}) async {
|
||||
final audioQuery = OnAudioQuery();
|
||||
final existing = {for (final s in await db.allSongs()) s.path: s};
|
||||
final covers = coverDir ??
|
||||
Directory(p.join((await getApplicationSupportDirectory()).path, 'covers'));
|
||||
await covers.create(recursive: true);
|
||||
|
||||
final all = await audioQuery.querySongs(
|
||||
sortType: SongSortType.TITLE,
|
||||
orderType: OrderType.ASC_OR_SMALLER,
|
||||
uriType: UriType.EXTERNAL,
|
||||
);
|
||||
final songs = all.where((s) => s.isMusic ?? true).toList();
|
||||
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final companions = <SongsCompanion>[];
|
||||
final livePaths = <String>[];
|
||||
var done = 0;
|
||||
|
||||
for (final s in songs) {
|
||||
livePaths.add(s.data);
|
||||
final prev = existing[s.data];
|
||||
final id = prev?.id ?? _uuid.v4();
|
||||
|
||||
var coverPath = prev?.coverPath;
|
||||
if (coverPath == null) {
|
||||
final art = await audioQuery.queryArtwork(s.id, ArtworkType.AUDIO, size: 512);
|
||||
if (art != null && art.isNotEmpty) {
|
||||
final f = File(p.join(covers.path, '$id.img'));
|
||||
await f.writeAsBytes(art);
|
||||
coverPath = f.path;
|
||||
}
|
||||
}
|
||||
|
||||
companions.add(SongsCompanion.insert(
|
||||
id: id,
|
||||
path: s.data,
|
||||
title: s.title,
|
||||
artist: Value(s.artist == '<unknown>' ? null : s.artist),
|
||||
album: Value(s.album),
|
||||
durationMs: Value(s.duration),
|
||||
coverPath: Value(coverPath),
|
||||
dateAddedMs: prev?.dateAddedMs ??
|
||||
(s.dateAdded != null ? s.dateAdded! * 1000 : now),
|
||||
updatedAtMs: now,
|
||||
));
|
||||
onProgress?.call(++done, songs.length);
|
||||
}
|
||||
|
||||
await db.upsertSongs(companions);
|
||||
await db.markMissing(livePaths, now);
|
||||
return companions.length;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
part 'database.g.dart';
|
||||
|
||||
/// Songs. Sync-fähig ab Tag 1: [id] ist eine stabile UUID, [updatedAtMs] und
|
||||
/// [deleted] (Tombstone) ermöglichen späteren Cloud-Sync ohne Schema-Umbau.
|
||||
class Songs extends Table {
|
||||
TextColumn get id => text()(); // uuid
|
||||
TextColumn get path => text().unique()();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get artist => text().nullable()();
|
||||
TextColumn get album => text().nullable()();
|
||||
IntColumn get durationMs => integer().nullable()();
|
||||
TextColumn get coverPath => text().nullable()();
|
||||
IntColumn get dateAddedMs => integer()();
|
||||
IntColumn get updatedAtMs => integer()();
|
||||
BoolColumn get deleted => boolean().withDefault(const Constant(false))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
/// Vom Nutzer gewählte Musikordner, die gescannt werden.
|
||||
class Folders extends Table {
|
||||
TextColumn get id => text()(); // uuid
|
||||
TextColumn get path => text().unique()();
|
||||
IntColumn get updatedAtMs => integer()();
|
||||
BoolColumn get deleted => boolean().withDefault(const Constant(false))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
@DriftDatabase(tables: [Songs, Folders])
|
||||
class MeloDb extends _$MeloDb {
|
||||
MeloDb([QueryExecutor? executor]) : super(executor ?? _open());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
|
||||
Stream<List<Song>> watchSongs() {
|
||||
return (select(songs)
|
||||
..where((s) => s.deleted.equals(false))
|
||||
..orderBy([(s) => OrderingTerm(expression: s.title)]))
|
||||
.watch();
|
||||
}
|
||||
|
||||
Stream<List<Song>> watchRecent({int limit = 50}) {
|
||||
return (select(songs)
|
||||
..where((s) => s.deleted.equals(false))
|
||||
..orderBy([
|
||||
(s) => OrderingTerm(expression: s.dateAddedMs, mode: OrderingMode.desc)
|
||||
])
|
||||
..limit(limit))
|
||||
.watch();
|
||||
}
|
||||
|
||||
Stream<List<Song>> searchSongs(String query) {
|
||||
final like = '%${query.toLowerCase()}%';
|
||||
return (select(songs)
|
||||
..where((s) =>
|
||||
s.deleted.equals(false) &
|
||||
(s.title.lower().like(like) |
|
||||
s.artist.lower().like(like) |
|
||||
s.album.lower().like(like)))
|
||||
..orderBy([(s) => OrderingTerm(expression: s.title)]))
|
||||
.watch();
|
||||
}
|
||||
|
||||
Future<List<Song>> allSongs() => select(songs).get();
|
||||
|
||||
Future<void> upsertSongs(List<SongsCompanion> items) async {
|
||||
await batch((b) => b.insertAllOnConflictUpdate(songs, items));
|
||||
}
|
||||
|
||||
/// Tombstone für Songs, deren Datei beim Scan nicht mehr gefunden wurde.
|
||||
Future<void> markMissing(List<String> livePaths, int now) async {
|
||||
final q = update(songs)..where((s) => s.deleted.equals(false));
|
||||
if (livePaths.isNotEmpty) {
|
||||
q.where((s) => s.path.isNotIn(livePaths));
|
||||
}
|
||||
await q.write(
|
||||
SongsCompanion(deleted: const Value(true), updatedAtMs: Value(now)),
|
||||
);
|
||||
}
|
||||
|
||||
Stream<List<Folder>> watchFolders() =>
|
||||
(select(folders)..where((f) => f.deleted.equals(false))).watch();
|
||||
|
||||
Future<List<Folder>> activeFolders() =>
|
||||
(select(folders)..where((f) => f.deleted.equals(false))).get();
|
||||
|
||||
Future<void> addFolder(FoldersCompanion folder) =>
|
||||
into(folders).insert(folder, onConflict: DoUpdate((_) => folder, target: [folders.path]));
|
||||
}
|
||||
|
||||
LazyDatabase _open() {
|
||||
return LazyDatabase(() async {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
final file = File(p.join(dir.path, 'melo.sqlite'));
|
||||
return NativeDatabase.createInBackground(file);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'database.dart';
|
||||
import 'library_service.dart';
|
||||
import 'permissions.dart';
|
||||
import 'song_list.dart';
|
||||
|
||||
class LibraryScreen extends StatelessWidget {
|
||||
const LibraryScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final db = context.read<MeloDb>();
|
||||
final lib = context.watch<LibraryService>();
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Bibliothek'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: addMusicLabel,
|
||||
icon: const Icon(Icons.create_new_folder_outlined),
|
||||
onPressed: lib.scanning ? null : lib.pickFolderAndScan,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Erneut scannen',
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: lib.scanning ? null : lib.rescan,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (lib.permissionDenied)
|
||||
MaterialBanner(
|
||||
backgroundColor: Colors.red.shade900,
|
||||
content: const Text('Zugriff auf Musik verweigert. '
|
||||
'Bitte in den Einstellungen erlauben.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: openMusicPermissionSettings,
|
||||
child: const Text('Einstellungen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (lib.scanning)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
children: [
|
||||
const LinearProgressIndicator(),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Scanne … ${lib.scanDone}'
|
||||
'${lib.scanTotal > 0 ? ' / ${lib.scanTotal}' : ''}',
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: StreamBuilder<List<Song>>(
|
||||
stream: db.watchSongs(),
|
||||
builder: (context, snapshot) {
|
||||
final songs = snapshot.data ?? const [];
|
||||
if (songs.isEmpty && !lib.scanning) {
|
||||
return const _Empty();
|
||||
}
|
||||
return SongList(songs);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Empty extends StatelessWidget {
|
||||
const _Empty();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lib = context.read<LibraryService>();
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.library_music, size: 64, color: Colors.white24),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Noch keine Musik',
|
||||
style: TextStyle(color: Colors.white54)),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.create_new_folder_outlined),
|
||||
label: Text(addMusicLabel),
|
||||
onPressed: lib.pickFolderAndScan,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'android_scan.dart';
|
||||
import 'database.dart';
|
||||
import 'permissions.dart';
|
||||
import 'scan_service.dart';
|
||||
|
||||
/// Beschriftung des Hinzufügen-/Scan-Buttons je Plattform.
|
||||
String get addMusicLabel =>
|
||||
Platform.isAndroid ? 'Musik scannen' : 'Musikordner hinzufügen';
|
||||
|
||||
/// Koordiniert Ordnerwahl und Scans; hält den Scan-Fortschritt für die UI.
|
||||
/// Android scannt automatisch über MediaStore (Scoped Storage lässt keinen
|
||||
/// direkten Dateizugriff zu); Desktop scannt vom Nutzer gewählte Ordner.
|
||||
class LibraryService extends ChangeNotifier {
|
||||
LibraryService(this.db);
|
||||
final MeloDb db;
|
||||
static const _uuid = Uuid();
|
||||
|
||||
bool scanning = false;
|
||||
int scanDone = 0;
|
||||
int scanTotal = 0;
|
||||
bool permissionDenied = false;
|
||||
|
||||
/// Auf Android: automatischer Geräte-Scan. Auf Desktop: Ordner wählen + scannen.
|
||||
Future<void> pickFolderAndScan() async {
|
||||
if (!await _ensurePermission()) return;
|
||||
if (Platform.isAndroid) {
|
||||
await _scan();
|
||||
return;
|
||||
}
|
||||
final path = await FilePicker.getDirectoryPath();
|
||||
if (path == null) return;
|
||||
await db.addFolder(FoldersCompanion.insert(
|
||||
id: _uuid.v4(),
|
||||
path: path,
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch,
|
||||
));
|
||||
await _scan();
|
||||
}
|
||||
|
||||
/// Scannt erneut (Android: ganzes Gerät; Desktop: alle gemerkten Ordner).
|
||||
Future<void> rescan() async {
|
||||
if (!await _ensurePermission()) return;
|
||||
await _scan();
|
||||
}
|
||||
|
||||
Future<bool> _ensurePermission() async {
|
||||
final granted = await ensureAudioPermission();
|
||||
permissionDenied = !granted;
|
||||
notifyListeners();
|
||||
return granted;
|
||||
}
|
||||
|
||||
Future<void> _scan() async {
|
||||
if (scanning) return;
|
||||
scanning = true;
|
||||
scanDone = 0;
|
||||
scanTotal = 0;
|
||||
notifyListeners();
|
||||
try {
|
||||
if (Platform.isAndroid) {
|
||||
await scanAndroidMediaStore(db, onProgress: _onProgress);
|
||||
} else {
|
||||
final folders = (await db.activeFolders()).map((f) => f.path).toList();
|
||||
await scanFolders(db, folders, onProgress: _onProgress);
|
||||
}
|
||||
} finally {
|
||||
scanning = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void _onProgress(int done, int total) {
|
||||
scanDone = done;
|
||||
scanTotal = total;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
/// Fragt die zum Lesen der Musik nötige Berechtigung an und meldet, ob erteilt.
|
||||
/// - Android 13+ (API 33): READ_MEDIA_AUDIO
|
||||
/// - Android < 13: READ_EXTERNAL_STORAGE
|
||||
/// - iOS / Desktop: kein Laufzeit-Grant nötig (Sandbox bzw. vom Nutzer gewählte
|
||||
/// Ordner sind bereits freigegeben).
|
||||
Future<bool> ensureAudioPermission() async {
|
||||
if (!Platform.isAndroid) return true;
|
||||
final info = await DeviceInfoPlugin().androidInfo;
|
||||
final permission =
|
||||
info.version.sdkInt >= 33 ? Permission.audio : Permission.storage;
|
||||
final status = await permission.request();
|
||||
return status.isGranted;
|
||||
}
|
||||
|
||||
/// Öffnet die System-Einstellungen der App (für dauerhaft verweigerte Rechte).
|
||||
Future<void> openMusicPermissionSettings() => openAppSettings();
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audio_metadata_reader/audio_metadata_reader.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'database.dart';
|
||||
|
||||
const _audioExt = {
|
||||
'.mp3', '.m4a', '.aac', '.flac', '.wav', '.ogg', '.opus', '.wma', '.aiff',
|
||||
'.aif', '.alac',
|
||||
};
|
||||
const _uuid = Uuid();
|
||||
|
||||
/// Scannt alle [folderPaths] rekursiv nach Audiodateien, liest Tags + Cover
|
||||
/// und schreibt sie in die DB. Bereits bekannte Dateien behalten ihre UUID
|
||||
/// (sync-stabil); verschwundene Dateien werden per Tombstone markiert.
|
||||
/// Gibt die Zahl der gefundenen Songs zurück.
|
||||
// ponytail: Tag-Parsing läuft synchron im UI-Isolate. Reicht für normale
|
||||
// Bibliotheken; bei >~mehreren Tausend Dateien in ein Isolate (compute) auslagern.
|
||||
Future<int> scanFolders(
|
||||
MeloDb db,
|
||||
List<String> folderPaths, {
|
||||
Directory? coverDir,
|
||||
void Function(int done, int total)? onProgress,
|
||||
}) async {
|
||||
final existing = {for (final s in await db.allSongs()) s.path: s};
|
||||
final covers = coverDir ??
|
||||
Directory(p.join((await getApplicationSupportDirectory()).path, 'covers'));
|
||||
await covers.create(recursive: true);
|
||||
|
||||
final files = <File>[];
|
||||
for (final folder in folderPaths) {
|
||||
final dir = Directory(folder);
|
||||
if (!dir.existsSync()) continue;
|
||||
await for (final entity in dir.list(recursive: true, followLinks: false)) {
|
||||
if (entity is File &&
|
||||
_audioExt.contains(p.extension(entity.path).toLowerCase())) {
|
||||
files.add(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final companions = <SongsCompanion>[];
|
||||
final livePaths = <String>[];
|
||||
var done = 0;
|
||||
|
||||
for (final file in files) {
|
||||
livePaths.add(file.path);
|
||||
final prev = existing[file.path];
|
||||
final id = prev?.id ?? _uuid.v4();
|
||||
|
||||
AudioMetadata? meta;
|
||||
try {
|
||||
meta = readMetadata(file, getImage: true);
|
||||
} catch (_) {
|
||||
// Unlesbare/kaputte Tags: Datei trotzdem mit Dateinamen aufnehmen.
|
||||
}
|
||||
|
||||
final rawTitle = meta?.title?.trim();
|
||||
final title = (rawTitle != null && rawTitle.isNotEmpty)
|
||||
? rawTitle
|
||||
: p.basenameWithoutExtension(file.path);
|
||||
|
||||
var coverPath = prev?.coverPath;
|
||||
final pics = meta?.pictures ?? const <Picture>[];
|
||||
if (pics.isNotEmpty && coverPath == null) {
|
||||
final f = File(p.join(covers.path, '$id.img'));
|
||||
await f.writeAsBytes(pics.first.bytes);
|
||||
coverPath = f.path;
|
||||
}
|
||||
|
||||
companions.add(SongsCompanion.insert(
|
||||
id: id,
|
||||
path: file.path,
|
||||
title: title,
|
||||
artist: Value(meta?.artist),
|
||||
album: Value(meta?.album),
|
||||
durationMs: Value(meta?.duration?.inMilliseconds),
|
||||
coverPath: Value(coverPath),
|
||||
dateAddedMs: prev?.dateAddedMs ?? now,
|
||||
updatedAtMs: now,
|
||||
));
|
||||
onProgress?.call(++done, files.length);
|
||||
}
|
||||
|
||||
await db.upsertSongs(companions);
|
||||
await db.markMissing(livePaths, now);
|
||||
return companions.length;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'database.dart';
|
||||
import 'song_list.dart';
|
||||
|
||||
class SearchScreen extends StatefulWidget {
|
||||
const SearchScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SearchScreen> createState() => _SearchScreenState();
|
||||
}
|
||||
|
||||
class _SearchScreenState extends State<SearchScreen> {
|
||||
String _query = '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final db = context.read<MeloDb>();
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: TextField(
|
||||
autofocus: false,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Titel, Künstler, Album …',
|
||||
border: InputBorder.none,
|
||||
prefixIcon: Icon(Icons.search),
|
||||
),
|
||||
onChanged: (v) => setState(() => _query = v.trim()),
|
||||
),
|
||||
),
|
||||
body: _query.isEmpty
|
||||
? const Center(
|
||||
child: Text('Suchbegriff eingeben',
|
||||
style: TextStyle(color: Colors.white54)),
|
||||
)
|
||||
: StreamBuilder<List<Song>>(
|
||||
stream: db.searchSongs(_query),
|
||||
builder: (context, snapshot) {
|
||||
final songs = snapshot.data ?? const [];
|
||||
if (songs.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Nichts gefunden',
|
||||
style: TextStyle(color: Colors.white54)),
|
||||
);
|
||||
}
|
||||
return SongList(songs);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../player/audio_handler.dart';
|
||||
import '../shared/cover.dart';
|
||||
import 'database.dart';
|
||||
import 'song_media.dart';
|
||||
|
||||
/// Scrollbare Songliste; Tippen spielt die ganze Liste ab dem Song ab.
|
||||
class SongList extends StatelessWidget {
|
||||
const SongList(this.songs, {super.key});
|
||||
final List<Song> songs;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final handler = context.read<MeloAudioHandler>();
|
||||
return ListView.builder(
|
||||
itemCount: songs.length,
|
||||
itemBuilder: (context, i) {
|
||||
final s = songs[i];
|
||||
return ListTile(
|
||||
leading: CoverImage(
|
||||
artUri: s.coverPath != null ? Uri.file(s.coverPath!) : null,
|
||||
size: 48,
|
||||
radius: 6,
|
||||
),
|
||||
title: Text(s.title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(s.artist ?? 'Unbekannt',
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
onTap: () => playSongs(handler, songs, i),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
|
||||
import '../player/audio_handler.dart';
|
||||
import 'database.dart';
|
||||
|
||||
MediaItem songToMediaItem(Song s) => MediaItem(
|
||||
id: Uri.file(s.path).toString(),
|
||||
title: s.title,
|
||||
artist: s.artist,
|
||||
album: s.album,
|
||||
duration: s.durationMs != null ? Duration(milliseconds: s.durationMs!) : null,
|
||||
artUri: s.coverPath != null ? Uri.file(s.coverPath!) : null,
|
||||
);
|
||||
|
||||
/// Spielt [songs] ab [startIndex] ab.
|
||||
Future<void> playSongs(MeloAudioHandler handler, List<Song> songs, int startIndex) {
|
||||
return handler.loadPlaylist(
|
||||
songs.map(songToMediaItem).toList(),
|
||||
startIndex: startIndex,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user