Initial commit

This commit is contained in:
Dustin-Mike Jens Hähnel
2026-08-16 18:46:06 +02:00
commit b5ab7bd9dd
126 changed files with 7952 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'library/database.dart';
import 'library/library_service.dart';
import 'library/song_list.dart';
/// Startseite: zuletzt hinzugefügte Songs, sonst ein Willkommens-Hinweis.
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
return Scaffold(
appBar: AppBar(
title: const Text('Melo',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 26)),
),
body: StreamBuilder<List<Song>>(
stream: db.watchRecent(),
builder: (context, snapshot) {
final songs = snapshot.data ?? const [];
if (songs.isEmpty) return const _Welcome();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text('Zuletzt hinzugefügt',
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
),
Expanded(child: SongList(songs)),
],
);
},
),
);
}
}
class _Welcome extends StatelessWidget {
const _Welcome();
@override
Widget build(BuildContext context) {
final lib = context.read<LibraryService>();
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.music_note, size: 72, color: Colors.white24),
const SizedBox(height: 12),
const Text('Willkommen bei Melo',
style: TextStyle(fontSize: 18)),
const SizedBox(height: 4),
const Text('Füge deine Musik hinzu, um loszulegen',
style: TextStyle(color: Colors.white54)),
const SizedBox(height: 20),
FilledButton.icon(
icon: const Icon(Icons.create_new_folder_outlined),
label: Text(addMusicLabel),
onPressed: lib.pickFolderAndScan,
),
],
),
);
}
}
+73
View File
@@ -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;
}
+108
View File
@@ -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
+103
View File
@@ -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,
),
],
),
);
}
}
+83
View File
@@ -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();
}
}
+21
View File
@@ -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();
+93
View File
@@ -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;
}
+52
View File
@@ -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);
},
),
);
}
}
+35
View File
@@ -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),
);
},
);
}
}
+21
View File
@@ -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,
);
}
+116
View File
@@ -0,0 +1,116 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'home_screen.dart';
import 'library/database.dart';
import 'library/library_screen.dart';
import 'library/library_service.dart';
import 'library/search_screen.dart';
import 'player/audio_handler.dart';
import 'player/mini_player.dart';
import 'shared/theme.dart';
late final MeloAudioHandler _handler;
late final MeloDb _db;
late final LibraryService _library;
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
_db = MeloDb();
_library = LibraryService(_db);
_handler = await AudioService.init(
builder: () => MeloAudioHandler(),
config: const AudioServiceConfig(
androidNotificationChannelId: 'de.baka.melo.audio',
androidNotificationChannelName: 'Melo',
androidNotificationOngoing: true,
),
);
runApp(const MeloApp());
}
class MeloApp extends StatelessWidget {
const MeloApp({super.key});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider<MeloAudioHandler>.value(value: _handler),
Provider<MeloDb>.value(value: _db),
ChangeNotifierProvider<LibraryService>.value(value: _library),
],
child: MaterialApp(
title: 'Melo',
debugShowCheckedModeBanner: false,
theme: MeloTheme.dark,
home: const HomeShell(),
),
);
}
}
class HomeShell extends StatefulWidget {
const HomeShell({super.key});
@override
State<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<HomeShell> {
int _index = 0;
static const _tabs = <Widget>[
HomeScreen(),
LibraryScreen(),
SearchScreen(),
_Placeholder(label: 'Playlists', hint: 'Playlists & Favoriten (P3)'),
_Placeholder(label: 'Recaps', hint: 'Wöchentlich/Monatlich/Jährlich (P5)'),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
Expanded(child: IndexedStack(index: _index, children: _tabs)),
const MiniPlayer(),
],
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _index,
onTap: (i) => setState(() => _index = i),
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(
icon: Icon(Icons.library_music), label: 'Bibliothek'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Suche'),
BottomNavigationBarItem(
icon: Icon(Icons.playlist_play), label: 'Playlists'),
BottomNavigationBarItem(icon: Icon(Icons.bar_chart), label: 'Recaps'),
],
),
);
}
}
class _Placeholder extends StatelessWidget {
const _Placeholder({required this.label, required this.hint});
final String label;
final String hint;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 8),
Text(hint, style: const TextStyle(color: Colors.white54)),
],
),
);
}
}
+113
View File
@@ -0,0 +1,113 @@
import 'package:audio_service/audio_service.dart';
import 'package:just_audio/just_audio.dart';
/// Kern der Wiedergabe: kapselt just_audio hinter audio_service,
/// damit Hintergrund-Wiedergabe + Lockscreen/Notification funktionieren.
class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
final AudioPlayer _player = AudioPlayer();
MeloAudioHandler() {
// just_audio-Events → audio_service PlaybackState
_player.playbackEventStream.map(_transformEvent).pipe(playbackState);
// Aktuellen Track ans System melden (Lockscreen/Notification).
// Bereits bekannte Dauer erhalten, damit sie nicht auf null zurückfällt.
_player.currentIndexStream.listen((index) {
final q = queue.value;
if (index != null && index < q.length) {
mediaItem.add(q[index].copyWith(duration: _player.duration));
}
});
// Ermittelte Dauer nachtragen
_player.durationStream.listen((duration) {
final item = mediaItem.value;
if (item != null && duration != null) {
mediaItem.add(item.copyWith(duration: duration));
}
});
}
/// Ersetzt die Warteschlange und startet ab [startIndex].
Future<void> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async {
queue.add(items);
final sources = items
.map((item) => AudioSource.uri(Uri.parse(item.id), tag: item))
.toList();
await _player.setAudioSources(sources, initialIndex: startIndex);
play();
}
@override
Future<void> play() => _player.play();
@override
Future<void> pause() => _player.pause();
@override
Future<void> stop() async {
await _player.stop();
await super.stop();
}
@override
Future<void> seek(Duration position) => _player.seek(position);
@override
Future<void> skipToNext() => _player.seekToNext();
@override
Future<void> skipToPrevious() => _player.seekToPrevious();
@override
Future<void> skipToQueueItem(int index) => _player.seek(Duration.zero, index: index);
@override
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
final enabled = shuffleMode == AudioServiceShuffleMode.all;
if (enabled) await _player.shuffle();
await _player.setShuffleModeEnabled(enabled);
}
@override
Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) {
return _player.setLoopMode(switch (repeatMode) {
AudioServiceRepeatMode.one => LoopMode.one,
AudioServiceRepeatMode.all => LoopMode.all,
_ => LoopMode.off,
});
}
PlaybackState _transformEvent(PlaybackEvent event) {
return PlaybackState(
controls: [
MediaControl.skipToPrevious,
if (_player.playing) MediaControl.pause else MediaControl.play,
MediaControl.skipToNext,
],
systemActions: const {
MediaAction.seek,
MediaAction.seekForward,
MediaAction.seekBackward,
},
androidCompactActionIndices: const [0, 1, 2],
processingState: const {
ProcessingState.idle: AudioProcessingState.idle,
ProcessingState.loading: AudioProcessingState.loading,
ProcessingState.buffering: AudioProcessingState.buffering,
ProcessingState.ready: AudioProcessingState.ready,
ProcessingState.completed: AudioProcessingState.completed,
}[_player.processingState]!,
playing: _player.playing,
updatePosition: _player.position,
bufferedPosition: _player.bufferedPosition,
speed: _player.speed,
queueIndex: event.currentIndex,
);
}
// Streams, die die UI beobachtet. Dauer direkt vom Player — zuverlässiger
// als mediaItem.duration, das beim Track-Wechsel kurzzeitig null sein kann.
Stream<Duration> get positionStream => _player.positionStream;
Stream<Duration?> get durationStream => _player.durationStream;
}
+72
View File
@@ -0,0 +1,72 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../shared/cover.dart';
import '../shared/theme.dart';
import 'audio_handler.dart';
import 'now_playing_screen.dart';
/// Kompakte Wiedergabe-Leiste über der Bottom-Nav.
/// Blendet sich aus, wenn nichts läuft.
class MiniPlayer extends StatelessWidget {
const MiniPlayer({super.key});
@override
Widget build(BuildContext context) {
final handler = context.read<MeloAudioHandler>();
return StreamBuilder<MediaItem?>(
stream: handler.mediaItem,
builder: (context, snapshot) {
final item = snapshot.data;
if (item == null) return const SizedBox.shrink();
return Material(
color: MeloTheme.surface,
child: InkWell(
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const NowPlayingScreen(),
)),
child: SizedBox(
height: 60,
child: Row(
children: [
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.all(8),
child: CoverImage(artUri: item.artUri, size: 44, radius: 6),
),
const SizedBox(width: 8),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.title,
maxLines: 1, overflow: TextOverflow.ellipsis),
Text(item.artist ?? 'Unbekannt',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white54, fontSize: 12)),
],
),
),
StreamBuilder<PlaybackState>(
stream: handler.playbackState,
builder: (context, snap) {
final playing = snap.data?.playing ?? false;
return IconButton(
icon: Icon(playing ? Icons.pause : Icons.play_arrow),
onPressed: playing ? handler.pause : handler.play,
);
},
),
],
),
),
),
);
},
);
}
}
+191
View File
@@ -0,0 +1,191 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../shared/cover.dart';
import '../shared/theme.dart';
import 'audio_handler.dart';
/// Vollbild-Wiedergabe: Cover, Titel, Fortschritt, Transport-Controls.
class NowPlayingScreen extends StatelessWidget {
const NowPlayingScreen({super.key});
@override
Widget build(BuildContext context) {
final handler = context.read<MeloAudioHandler>();
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.keyboard_arrow_down),
onPressed: () => Navigator.of(context).maybePop(),
),
),
body: SafeArea(
child: StreamBuilder<MediaItem?>(
stream: handler.mediaItem,
builder: (context, snapshot) {
final item = snapshot.data;
if (item == null) {
return const Center(child: Text('Nichts in Wiedergabe'));
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
Expanded(child: Center(child: _Cover(item: item))),
const SizedBox(height: 24),
Text(
item.title,
style: Theme.of(context).textTheme.headlineSmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
item.artist ?? 'Unbekannt',
style: const TextStyle(color: Colors.white54),
),
const SizedBox(height: 24),
_ProgressBar(handler: handler),
const _Controls(),
const SizedBox(height: 16),
],
),
);
},
),
),
);
}
}
class _Cover extends StatelessWidget {
const _Cover({required this.item});
final MediaItem item;
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: 1,
child: CoverImage(artUri: item.artUri, radius: 16),
);
}
}
class _ProgressBar extends StatelessWidget {
const _ProgressBar({required this.handler});
final MeloAudioHandler handler;
@override
Widget build(BuildContext context) {
return StreamBuilder<Duration?>(
stream: handler.durationStream,
builder: (context, dSnap) {
final total = dSnap.data ?? Duration.zero;
return StreamBuilder<Duration>(
stream: handler.positionStream,
builder: (context, pSnap) {
var position = pSnap.data ?? Duration.zero;
final hasDuration = total > Duration.zero;
if (hasDuration && position > total) position = total;
final maxMs = hasDuration ? total.inMilliseconds.toDouble() : 1.0;
final valueMs =
position.inMilliseconds.toDouble().clamp(0.0, maxMs).toDouble();
return Column(
children: [
Slider(
value: valueMs,
max: maxMs,
onChanged: hasDuration
? (v) => handler.seek(Duration(milliseconds: v.round()))
: null,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(_fmt(position),
style: const TextStyle(color: Colors.white54)),
Text(_fmt(total),
style: const TextStyle(color: Colors.white54)),
],
),
),
],
);
},
);
},
);
}
static String _fmt(Duration d) {
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$m:$s';
}
}
class _Controls extends StatelessWidget {
const _Controls();
@override
Widget build(BuildContext context) {
final handler = context.read<MeloAudioHandler>();
return StreamBuilder<PlaybackState>(
stream: handler.playbackState,
builder: (context, snapshot) {
final state = snapshot.data;
final playing = state?.playing ?? false;
final shuffle = state?.shuffleMode == AudioServiceShuffleMode.all;
final repeat = state?.repeatMode ?? AudioServiceRepeatMode.none;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
icon: Icon(Icons.shuffle,
color: shuffle ? MeloTheme.red : Colors.white54),
onPressed: () => handler.setShuffleMode(shuffle
? AudioServiceShuffleMode.none
: AudioServiceShuffleMode.all),
),
IconButton(
iconSize: 40,
icon: const Icon(Icons.skip_previous),
onPressed: handler.skipToPrevious,
),
IconButton(
iconSize: 64,
icon: Icon(playing ? Icons.pause_circle : Icons.play_circle,
color: MeloTheme.red),
onPressed: playing ? handler.pause : handler.play,
),
IconButton(
iconSize: 40,
icon: const Icon(Icons.skip_next),
onPressed: handler.skipToNext,
),
IconButton(
icon: Icon(
repeat == AudioServiceRepeatMode.one
? Icons.repeat_one
: Icons.repeat,
color: repeat == AudioServiceRepeatMode.none
? Colors.white54
: MeloTheme.red,
),
onPressed: () => handler.setRepeatMode(switch (repeat) {
AudioServiceRepeatMode.none => AudioServiceRepeatMode.all,
AudioServiceRepeatMode.all => AudioServiceRepeatMode.one,
_ => AudioServiceRepeatMode.none,
}),
),
],
);
},
);
}
}
+39
View File
@@ -0,0 +1,39 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'theme.dart';
/// Cover-Bild aus einer file-URI, mit Noten-Platzhalter als Fallback.
class CoverImage extends StatelessWidget {
const CoverImage({super.key, required this.artUri, this.size, this.radius = 8});
final Uri? artUri;
final double? size;
final double radius;
@override
Widget build(BuildContext context) {
final placeholder = Container(
width: size,
height: size,
color: MeloTheme.surface,
child: LayoutBuilder(
builder: (_, c) {
final s = size ?? (c.hasBoundedWidth ? c.maxWidth : 48.0);
return Icon(Icons.music_note, size: s * 0.4, color: MeloTheme.red);
},
),
);
Widget child = placeholder;
if (artUri != null && artUri!.scheme == 'file') {
child = Image.file(
File(artUri!.toFilePath()),
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => placeholder,
);
}
return ClipRRect(borderRadius: BorderRadius.circular(radius), child: child);
}
}
+35
View File
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
/// Melo-Theme: Schwarz + Rot.
class MeloTheme {
static const Color red = Color(0xFFE50914);
static const Color black = Color(0xFF0A0A0A);
static const Color surface = Color(0xFF161616);
static ThemeData get dark {
final scheme = ColorScheme.fromSeed(
seedColor: red,
brightness: Brightness.dark,
).copyWith(
primary: red,
surface: black,
);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: black,
cardColor: surface,
sliderTheme: const SliderThemeData(
activeTrackColor: red,
thumbColor: red,
trackHeight: 3,
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: surface,
selectedItemColor: red,
unselectedItemColor: Colors.white54,
type: BottomNavigationBarType.fixed,
),
);
}
}