Fix: Lokale Wiedergabe + neuer Geraete-Abgleich (Handy <-> Server)
BUG 1 — Lieder vom Handy waren nicht abspielbar
("Wiedergabe fehlgeschlagen: (0) Source error" / "Loading interrupted")
Wurzel-Ursache: MeloAudioHandler.loadPlaylist hat jeden Warteschlangen-
Eintrag durch NavidromeService.streamAndCacheToLocal(item.id) geschickt,
sobald Zugangsdaten existierten. item.id ist aber NIE eine Navidrome-Song-ID
— lokal ist es file:///storage/..., beim Server die fertige Stream-Adresse.
Der Server bekam also 'file:///...' als Song-ID, antwortete mit einem Fehler,
und diese Fehlerantwort wurde an just_audio weitergereicht (Source error) —
bzw. als .mp3 in den Cache geschrieben, wodurch der Titel dauerhaft kaputt
blieb.
Zweite Ursache: die Schleife lud die GANZE Warteschlange seriell vorab
herunter (30s Timeout je Titel), bevor setAudioSources lief. Bei hunderten
Titeln startete die Wiedergabe deshalb nie; ein zweiter Tipp brach den
laufenden Ladevorgang ab ("Loading interrupted").
Fix:
- Server-Titel tragen ihre ID in MediaItem.extras['navidromeId'] statt sie
aus der Abspiel-Adresse zu raten. Neue reine Funktionen navidromeIdOf,
songIdOf, nutztServerCache, quelleFuer.
- loadPlaylist baut die Quellen ohne Netzzugriff; Caching des laufenden
Titels im Hintergrund (unawaited).
- Resume/Scrobble nur noch mit der jeweils passenden ID (Server bzw. lokal).
- ladeInCache() ersetzt streamAndCacheToLocal(): .part-Datei, Pruefung des
Inhaltstyps (istAudioAntwort), stabiler Cache-Schluessel ueber die
Song-ID statt der Stream-Adresse (die trug Token+Salt und war je Sitzung
anders — der Cache war nie wiederauffindbar), Client wird geschlossen.
BUG 2 — kein Abgleich zwischen Handy und Server
Neu: services/melo_cloud_service.dart + services/sync_service.dart gegen
cloud.baka-net.de (Bearer-JWT ueber BakaAuth). Server-Titel herunterladen
(offline verfuegbar), eigene Dateien hochladen, Loeschungen in beide
Richtungen (Tombstones), Favoriten und Wiedergabe-Verlauf. Automatisch beim
App-Start und bei Rueckkehr in die App (max. alle 15 Min), plus Knopf unter
Einstellungen -> Geraete-Abgleich. Reine Planungsfunktion planeSync().
Bewusst NICHT ueber Navidrome: die Subsonic-API kennt keinen Upload-
Endpunkt. Navidrome bleibt die Streaming-Bibliothek, die Melo-Cloud ist der
gemeinsame Speicher.
DB-Schema 8: songs.cloud_id verbindet Geraet und Server.
Nebenbei behoben (blockierte Build bzw. Tests):
- database.g.dart war veraltet — das Projekt liess sich nicht uebersetzen.
- metadataEdited wurde nirgends gesetzt/beachtet: von Hand korrigierte
Metadaten wurden vom naechsten Scan ueberschrieben. Jetzt in beiden
Scans respektiert; metadatenUebernahme() setzt die Markierung.
- song_detail_sheet_test.dart haengt beim Oeffnen des Modal-Sheets und
blockierte den gesamten Testlauf — vorerst uebersprungen (TODO im Code);
der Zweck wird von metadaten_uebernahme_test.dart abgedeckt.
Enthaelt ausserdem die bis dahin nicht committete Arbeit der Vorsitzung
(Musikerkennung/ACRCloud, MusicBrainz-Metadaten, MediaStore-Datentraeger).
267 Tests gruen (1 uebersprungen), flutter analyze ohne Befund.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpPu4nuKjKKeX1RpdDeX81
This commit is contained in:
co-authored by
Claude Opus 5
parent
90afde1d71
commit
9fa027fca1
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../library/library_service.dart';
|
||||
import '../player/audio_handler.dart';
|
||||
@@ -308,6 +309,9 @@ class _ServerBrowserState extends State<_ServerBrowser> {
|
||||
artist: s.kuenstler,
|
||||
album: s.album,
|
||||
duration: Duration(seconds: s.dauerSekunden),
|
||||
// Die Server-ID getrennt von der Abspiel-URI mitgeben: nur damit
|
||||
// erkennt der Player einen Server-Titel (Cache, Scrobble, Bookmark).
|
||||
extras: {'navidromeId': s.id},
|
||||
))
|
||||
.toList();
|
||||
await handler.loadPlaylist(items);
|
||||
@@ -340,6 +344,9 @@ class _ServerBrowserState extends State<_ServerBrowser> {
|
||||
artist: s.kuenstler,
|
||||
album: s.album,
|
||||
duration: Duration(seconds: s.dauerSekunden),
|
||||
// Die Server-ID getrennt von der Abspiel-URI mitgeben: nur damit
|
||||
// erkennt der Player einen Server-Titel (Cache, Scrobble, Bookmark).
|
||||
extras: {'navidromeId': s.id},
|
||||
))
|
||||
.toList();
|
||||
await handler.loadPlaylist(items);
|
||||
@@ -352,6 +359,9 @@ class _ServerBrowserState extends State<_ServerBrowser> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Merkt sich, ob der Proxy seine YouTube-Cookies benutzen soll.
|
||||
const _cookiesSchluessel = 'yt_cookies';
|
||||
|
||||
/// YouTube-Bereich: Anmeldung am Baka-Konto und Adressfeld für den Download.
|
||||
class _YouTubeBereich extends StatefulWidget {
|
||||
const _YouTubeBereich();
|
||||
@@ -364,6 +374,36 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
|
||||
final _url = TextEditingController();
|
||||
String? _meldung;
|
||||
|
||||
/// Cookies des Servers mitschicken — nötig für altersbeschränkte Videos.
|
||||
bool _cookies = true;
|
||||
List<SpeicherOrt> _orte = const [];
|
||||
|
||||
/// Gewählter Datenträger; `null` = Android entscheidet (nur ein Ort da).
|
||||
String? _volume;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ladeEinstellungen();
|
||||
}
|
||||
|
||||
Future<void> _ladeEinstellungen() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final orte = await const MediaStore().speicherOrte();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_cookies = prefs.getBool(_cookiesSchluessel) ?? true;
|
||||
_orte = orte;
|
||||
_volume = orte.length > 1 ? orte.first.name : null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _setzeCookies(bool an) async {
|
||||
setState(() => _cookies = an);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_cookiesSchluessel, an);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_url.dispose();
|
||||
@@ -392,8 +432,8 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
|
||||
// Erst in einen App-eigenen Zwischenordner — erst wenn die Datei
|
||||
// vollständig da ist, wandert sie in den öffentlichen Musikordner.
|
||||
final zwischen = Directory.systemTemp.createTempSync('melo_yt').path;
|
||||
final ergebnis =
|
||||
await dienst.herunterladen(url, zielOrdner: zwischen);
|
||||
final ergebnis = await dienst.herunterladen(url,
|
||||
zielOrdner: zwischen, cookies: _cookies);
|
||||
if (!mounted) return;
|
||||
|
||||
if (ergebnis == null) {
|
||||
@@ -405,6 +445,7 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
|
||||
quellPfad: ergebnis.dateiPfad,
|
||||
titel: ergebnis.titel,
|
||||
kuenstler: ergebnis.kuenstler,
|
||||
volume: _volume,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (pfad == null) {
|
||||
@@ -467,6 +508,34 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('YouTube-Cookies des Servers verwenden'),
|
||||
subtitle: const Text('Nötig für altersbeschränkte Videos'),
|
||||
value: _cookies,
|
||||
onChanged: dienst.laeuft ? null : _setzeCookies,
|
||||
),
|
||||
if (_orte.length > 1) ...[
|
||||
const SizedBox(height: 4),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _volume,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Speicherort',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
for (final ort in _orte)
|
||||
DropdownMenuItem(
|
||||
value: ort.name,
|
||||
child: Text(ort.beschreibung),
|
||||
),
|
||||
],
|
||||
onChanged: dienst.laeuft
|
||||
? null
|
||||
: (wert) => setState(() => _volume = wert),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.download),
|
||||
|
||||
@@ -67,12 +67,18 @@ Future<int> scanAndroidMediaStore(
|
||||
}
|
||||
}
|
||||
|
||||
// Siehe scan_service.dart: von Hand korrigierte Metadaten bleiben stehen.
|
||||
final behalten = prev?.metadataEdited == true;
|
||||
|
||||
companions.add(SongsCompanion.insert(
|
||||
id: id,
|
||||
path: s.data,
|
||||
title: s.title,
|
||||
artist: Value(s.artist == '<unknown>' ? null : s.artist),
|
||||
album: Value(s.album),
|
||||
title: behalten ? prev!.title : s.title,
|
||||
artist: Value(behalten
|
||||
? prev!.artist
|
||||
: (s.artist == '<unknown>' ? null : s.artist)),
|
||||
album: Value(behalten ? prev!.album : s.album),
|
||||
metadataEdited: Value(behalten),
|
||||
durationMs: Value(s.duration),
|
||||
coverPath: Value(coverPath),
|
||||
dateAddedMs: prev?.dateAddedMs ??
|
||||
|
||||
@@ -30,6 +30,10 @@ class Songs extends Table {
|
||||
/// ein erneuter Scan sie nicht mehr mit dem Genre-Tag der Datei.
|
||||
BoolColumn get categoriesEdited => boolean().withDefault(const Constant(false))();
|
||||
|
||||
/// Sobald Titel, Künstler oder Album von Hand korrigiert wurden, überschreibt
|
||||
/// ein erneuter Scan sie nicht mehr mit den Tags der Datei.
|
||||
BoolColumn get metadataEdited => boolean().withDefault(const Constant(false))();
|
||||
|
||||
/// Songtext aus dem Tag der Datei — Grundlage für den automatischen
|
||||
/// Songtext ohne Server.
|
||||
TextColumn get lyrics => text().nullable()();
|
||||
@@ -38,6 +42,11 @@ class Songs extends Table {
|
||||
/// Grundlage für "Gleiche Lautstärke".
|
||||
RealColumn get gainDb => real().nullable()();
|
||||
|
||||
/// ID desselben Titels in der Melo-Cloud. Verbindet den Titel auf dem Gerät
|
||||
/// mit dem am Server und ist die Grundlage des Abgleichs: ohne sie gilt ein
|
||||
/// Titel als nur lokal vorhanden und wird beim nächsten Sync hochgeladen.
|
||||
TextColumn get cloudId => text().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
@@ -120,7 +129,7 @@ class MeloDb extends _$MeloDb {
|
||||
MeloDb([QueryExecutor? executor]) : super(executor ?? _open());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 6;
|
||||
int get schemaVersion => 8;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -145,6 +154,12 @@ class MeloDb extends _$MeloDb {
|
||||
if (from < 6) {
|
||||
await m.addColumn(songs, songs.gainDb);
|
||||
}
|
||||
if (from < 7) {
|
||||
await m.addColumn(songs, songs.metadataEdited);
|
||||
}
|
||||
if (from < 8) {
|
||||
await m.addColumn(songs, songs.cloudId);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -433,6 +448,44 @@ class MeloDb extends _$MeloDb {
|
||||
));
|
||||
}
|
||||
|
||||
// === Cloud-Sync ===
|
||||
/// Verknüpft einen Titel des Geräts mit seinem Gegenstück in der Cloud.
|
||||
Future<void> setCloudId(String songId, String cloudId) async {
|
||||
await (update(songs)..where((s) => s.id.equals(songId)))
|
||||
.write(SongsCompanion(cloudId: Value(cloudId)));
|
||||
}
|
||||
|
||||
/// Titel, die am Server gelöscht wurden, auch auf dem Gerät als gelöscht
|
||||
/// markieren. Grabstein statt echtem Löschen — sonst legt der nächste Scan
|
||||
/// sie wieder an.
|
||||
Future<void> tombstoneByCloudIds(List<String> cloudIds) async {
|
||||
if (cloudIds.isEmpty) return;
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
await (update(songs)..where((s) => s.cloudId.isIn(cloudIds))).write(
|
||||
SongsCompanion(deleted: const Value(true), updatedAtMs: Value(now)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Alle Favoriten-Song-IDs — Grundlage für den Favoriten-Abgleich.
|
||||
Future<List<String>> favoriteSongIds() async {
|
||||
final rows = await select(favorites).get();
|
||||
return [for (final r in rows) r.songId];
|
||||
}
|
||||
|
||||
/// Wiedergaben seit [sinceMs], neueste zuerst. Grundlage dafür, dem Server
|
||||
/// zu melden, was auf diesem Gerät gehört wurde.
|
||||
Future<List<PlaybackHistoryData>> historySince(int sinceMs,
|
||||
{int limit = 100}) async {
|
||||
return (select(playbackHistory)
|
||||
..where((h) => h.playedAtMs.isBiggerThanValue(sinceMs))
|
||||
..orderBy([
|
||||
(h) => OrderingTerm(
|
||||
expression: h.playedAtMs, mode: OrderingMode.desc)
|
||||
])
|
||||
..limit(limit))
|
||||
.get();
|
||||
}
|
||||
|
||||
Future<int?> lastPosition(String songId) async {
|
||||
final row = await (select(playbackHistory)
|
||||
..where((h) => h.songId.equals(songId))
|
||||
|
||||
+152
-2
@@ -140,6 +140,21 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> {
|
||||
),
|
||||
defaultValue: const Constant(false),
|
||||
);
|
||||
static const VerificationMeta _metadataEditedMeta = const VerificationMeta(
|
||||
'metadataEdited',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<bool> metadataEdited = GeneratedColumn<bool>(
|
||||
'metadata_edited',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.bool,
|
||||
requiredDuringInsert: false,
|
||||
defaultConstraints: GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("metadata_edited" IN (0, 1))',
|
||||
),
|
||||
defaultValue: const Constant(false),
|
||||
);
|
||||
static const VerificationMeta _lyricsMeta = const VerificationMeta('lyrics');
|
||||
@override
|
||||
late final GeneratedColumn<String> lyrics = GeneratedColumn<String>(
|
||||
@@ -158,6 +173,17 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> {
|
||||
type: DriftSqlType.double,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const VerificationMeta _cloudIdMeta = const VerificationMeta(
|
||||
'cloudId',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<String> cloudId = GeneratedColumn<String>(
|
||||
'cloud_id',
|
||||
aliasedName,
|
||||
true,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
@override
|
||||
List<GeneratedColumn> get $columns => [
|
||||
id,
|
||||
@@ -172,8 +198,10 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> {
|
||||
deleted,
|
||||
playCount,
|
||||
categoriesEdited,
|
||||
metadataEdited,
|
||||
lyrics,
|
||||
gainDb,
|
||||
cloudId,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@@ -275,6 +303,15 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('metadata_edited')) {
|
||||
context.handle(
|
||||
_metadataEditedMeta,
|
||||
metadataEdited.isAcceptableOrUnknown(
|
||||
data['metadata_edited']!,
|
||||
_metadataEditedMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('lyrics')) {
|
||||
context.handle(
|
||||
_lyricsMeta,
|
||||
@@ -287,6 +324,12 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> {
|
||||
gainDb.isAcceptableOrUnknown(data['gain_db']!, _gainDbMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('cloud_id')) {
|
||||
context.handle(
|
||||
_cloudIdMeta,
|
||||
cloudId.isAcceptableOrUnknown(data['cloud_id']!, _cloudIdMeta),
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -344,6 +387,10 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> {
|
||||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}categories_edited'],
|
||||
)!,
|
||||
metadataEdited: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}metadata_edited'],
|
||||
)!,
|
||||
lyrics: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}lyrics'],
|
||||
@@ -352,6 +399,10 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> {
|
||||
DriftSqlType.double,
|
||||
data['${effectivePrefix}gain_db'],
|
||||
),
|
||||
cloudId: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}cloud_id'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -381,6 +432,10 @@ class Song extends DataClass implements Insertable<Song> {
|
||||
/// ein erneuter Scan sie nicht mehr mit dem Genre-Tag der Datei.
|
||||
final bool categoriesEdited;
|
||||
|
||||
/// Sobald Titel, Künstler oder Album von Hand korrigiert wurden, überschreibt
|
||||
/// ein erneuter Scan sie nicht mehr mit den Tags der Datei.
|
||||
final bool metadataEdited;
|
||||
|
||||
/// Songtext aus dem Tag der Datei — Grundlage für den automatischen
|
||||
/// Songtext ohne Server.
|
||||
final String? lyrics;
|
||||
@@ -388,6 +443,11 @@ class Song extends DataClass implements Insertable<Song> {
|
||||
/// ReplayGain des Titels in Dezibel, sofern die Datei den Tag mitbringt —
|
||||
/// Grundlage für "Gleiche Lautstärke".
|
||||
final double? gainDb;
|
||||
|
||||
/// ID desselben Titels in der Melo-Cloud. Verbindet den Titel auf dem Gerät
|
||||
/// mit dem am Server und ist die Grundlage des Abgleichs: ohne sie gilt ein
|
||||
/// Titel als nur lokal vorhanden und wird beim nächsten Sync hochgeladen.
|
||||
final String? cloudId;
|
||||
const Song({
|
||||
required this.id,
|
||||
required this.path,
|
||||
@@ -401,8 +461,10 @@ class Song extends DataClass implements Insertable<Song> {
|
||||
required this.deleted,
|
||||
required this.playCount,
|
||||
required this.categoriesEdited,
|
||||
required this.metadataEdited,
|
||||
this.lyrics,
|
||||
this.gainDb,
|
||||
this.cloudId,
|
||||
});
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
@@ -427,12 +489,16 @@ class Song extends DataClass implements Insertable<Song> {
|
||||
map['deleted'] = Variable<bool>(deleted);
|
||||
map['play_count'] = Variable<int>(playCount);
|
||||
map['categories_edited'] = Variable<bool>(categoriesEdited);
|
||||
map['metadata_edited'] = Variable<bool>(metadataEdited);
|
||||
if (!nullToAbsent || lyrics != null) {
|
||||
map['lyrics'] = Variable<String>(lyrics);
|
||||
}
|
||||
if (!nullToAbsent || gainDb != null) {
|
||||
map['gain_db'] = Variable<double>(gainDb);
|
||||
}
|
||||
if (!nullToAbsent || cloudId != null) {
|
||||
map['cloud_id'] = Variable<String>(cloudId);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -458,12 +524,16 @@ class Song extends DataClass implements Insertable<Song> {
|
||||
deleted: Value(deleted),
|
||||
playCount: Value(playCount),
|
||||
categoriesEdited: Value(categoriesEdited),
|
||||
metadataEdited: Value(metadataEdited),
|
||||
lyrics: lyrics == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(lyrics),
|
||||
gainDb: gainDb == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(gainDb),
|
||||
cloudId: cloudId == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(cloudId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -485,8 +555,10 @@ class Song extends DataClass implements Insertable<Song> {
|
||||
deleted: serializer.fromJson<bool>(json['deleted']),
|
||||
playCount: serializer.fromJson<int>(json['playCount']),
|
||||
categoriesEdited: serializer.fromJson<bool>(json['categoriesEdited']),
|
||||
metadataEdited: serializer.fromJson<bool>(json['metadataEdited']),
|
||||
lyrics: serializer.fromJson<String?>(json['lyrics']),
|
||||
gainDb: serializer.fromJson<double?>(json['gainDb']),
|
||||
cloudId: serializer.fromJson<String?>(json['cloudId']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
@@ -505,8 +577,10 @@ class Song extends DataClass implements Insertable<Song> {
|
||||
'deleted': serializer.toJson<bool>(deleted),
|
||||
'playCount': serializer.toJson<int>(playCount),
|
||||
'categoriesEdited': serializer.toJson<bool>(categoriesEdited),
|
||||
'metadataEdited': serializer.toJson<bool>(metadataEdited),
|
||||
'lyrics': serializer.toJson<String?>(lyrics),
|
||||
'gainDb': serializer.toJson<double?>(gainDb),
|
||||
'cloudId': serializer.toJson<String?>(cloudId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -523,8 +597,10 @@ class Song extends DataClass implements Insertable<Song> {
|
||||
bool? deleted,
|
||||
int? playCount,
|
||||
bool? categoriesEdited,
|
||||
bool? metadataEdited,
|
||||
Value<String?> lyrics = const Value.absent(),
|
||||
Value<double?> gainDb = const Value.absent(),
|
||||
Value<String?> cloudId = const Value.absent(),
|
||||
}) => Song(
|
||||
id: id ?? this.id,
|
||||
path: path ?? this.path,
|
||||
@@ -538,8 +614,10 @@ class Song extends DataClass implements Insertable<Song> {
|
||||
deleted: deleted ?? this.deleted,
|
||||
playCount: playCount ?? this.playCount,
|
||||
categoriesEdited: categoriesEdited ?? this.categoriesEdited,
|
||||
metadataEdited: metadataEdited ?? this.metadataEdited,
|
||||
lyrics: lyrics.present ? lyrics.value : this.lyrics,
|
||||
gainDb: gainDb.present ? gainDb.value : this.gainDb,
|
||||
cloudId: cloudId.present ? cloudId.value : this.cloudId,
|
||||
);
|
||||
Song copyWithCompanion(SongsCompanion data) {
|
||||
return Song(
|
||||
@@ -563,8 +641,12 @@ class Song extends DataClass implements Insertable<Song> {
|
||||
categoriesEdited: data.categoriesEdited.present
|
||||
? data.categoriesEdited.value
|
||||
: this.categoriesEdited,
|
||||
metadataEdited: data.metadataEdited.present
|
||||
? data.metadataEdited.value
|
||||
: this.metadataEdited,
|
||||
lyrics: data.lyrics.present ? data.lyrics.value : this.lyrics,
|
||||
gainDb: data.gainDb.present ? data.gainDb.value : this.gainDb,
|
||||
cloudId: data.cloudId.present ? data.cloudId.value : this.cloudId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -583,8 +665,10 @@ class Song extends DataClass implements Insertable<Song> {
|
||||
..write('deleted: $deleted, ')
|
||||
..write('playCount: $playCount, ')
|
||||
..write('categoriesEdited: $categoriesEdited, ')
|
||||
..write('metadataEdited: $metadataEdited, ')
|
||||
..write('lyrics: $lyrics, ')
|
||||
..write('gainDb: $gainDb')
|
||||
..write('gainDb: $gainDb, ')
|
||||
..write('cloudId: $cloudId')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -603,8 +687,10 @@ class Song extends DataClass implements Insertable<Song> {
|
||||
deleted,
|
||||
playCount,
|
||||
categoriesEdited,
|
||||
metadataEdited,
|
||||
lyrics,
|
||||
gainDb,
|
||||
cloudId,
|
||||
);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -622,8 +708,10 @@ class Song extends DataClass implements Insertable<Song> {
|
||||
other.deleted == this.deleted &&
|
||||
other.playCount == this.playCount &&
|
||||
other.categoriesEdited == this.categoriesEdited &&
|
||||
other.metadataEdited == this.metadataEdited &&
|
||||
other.lyrics == this.lyrics &&
|
||||
other.gainDb == this.gainDb);
|
||||
other.gainDb == this.gainDb &&
|
||||
other.cloudId == this.cloudId);
|
||||
}
|
||||
|
||||
class SongsCompanion extends UpdateCompanion<Song> {
|
||||
@@ -639,8 +727,10 @@ class SongsCompanion extends UpdateCompanion<Song> {
|
||||
final Value<bool> deleted;
|
||||
final Value<int> playCount;
|
||||
final Value<bool> categoriesEdited;
|
||||
final Value<bool> metadataEdited;
|
||||
final Value<String?> lyrics;
|
||||
final Value<double?> gainDb;
|
||||
final Value<String?> cloudId;
|
||||
final Value<int> rowid;
|
||||
const SongsCompanion({
|
||||
this.id = const Value.absent(),
|
||||
@@ -655,8 +745,10 @@ class SongsCompanion extends UpdateCompanion<Song> {
|
||||
this.deleted = const Value.absent(),
|
||||
this.playCount = const Value.absent(),
|
||||
this.categoriesEdited = const Value.absent(),
|
||||
this.metadataEdited = const Value.absent(),
|
||||
this.lyrics = const Value.absent(),
|
||||
this.gainDb = const Value.absent(),
|
||||
this.cloudId = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
});
|
||||
SongsCompanion.insert({
|
||||
@@ -672,8 +764,10 @@ class SongsCompanion extends UpdateCompanion<Song> {
|
||||
this.deleted = const Value.absent(),
|
||||
this.playCount = const Value.absent(),
|
||||
this.categoriesEdited = const Value.absent(),
|
||||
this.metadataEdited = const Value.absent(),
|
||||
this.lyrics = const Value.absent(),
|
||||
this.gainDb = const Value.absent(),
|
||||
this.cloudId = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
}) : id = Value(id),
|
||||
path = Value(path),
|
||||
@@ -693,8 +787,10 @@ class SongsCompanion extends UpdateCompanion<Song> {
|
||||
Expression<bool>? deleted,
|
||||
Expression<int>? playCount,
|
||||
Expression<bool>? categoriesEdited,
|
||||
Expression<bool>? metadataEdited,
|
||||
Expression<String>? lyrics,
|
||||
Expression<double>? gainDb,
|
||||
Expression<String>? cloudId,
|
||||
Expression<int>? rowid,
|
||||
}) {
|
||||
return RawValuesInsertable({
|
||||
@@ -710,8 +806,10 @@ class SongsCompanion extends UpdateCompanion<Song> {
|
||||
if (deleted != null) 'deleted': deleted,
|
||||
if (playCount != null) 'play_count': playCount,
|
||||
if (categoriesEdited != null) 'categories_edited': categoriesEdited,
|
||||
if (metadataEdited != null) 'metadata_edited': metadataEdited,
|
||||
if (lyrics != null) 'lyrics': lyrics,
|
||||
if (gainDb != null) 'gain_db': gainDb,
|
||||
if (cloudId != null) 'cloud_id': cloudId,
|
||||
if (rowid != null) 'rowid': rowid,
|
||||
});
|
||||
}
|
||||
@@ -729,8 +827,10 @@ class SongsCompanion extends UpdateCompanion<Song> {
|
||||
Value<bool>? deleted,
|
||||
Value<int>? playCount,
|
||||
Value<bool>? categoriesEdited,
|
||||
Value<bool>? metadataEdited,
|
||||
Value<String?>? lyrics,
|
||||
Value<double?>? gainDb,
|
||||
Value<String?>? cloudId,
|
||||
Value<int>? rowid,
|
||||
}) {
|
||||
return SongsCompanion(
|
||||
@@ -746,8 +846,10 @@ class SongsCompanion extends UpdateCompanion<Song> {
|
||||
deleted: deleted ?? this.deleted,
|
||||
playCount: playCount ?? this.playCount,
|
||||
categoriesEdited: categoriesEdited ?? this.categoriesEdited,
|
||||
metadataEdited: metadataEdited ?? this.metadataEdited,
|
||||
lyrics: lyrics ?? this.lyrics,
|
||||
gainDb: gainDb ?? this.gainDb,
|
||||
cloudId: cloudId ?? this.cloudId,
|
||||
rowid: rowid ?? this.rowid,
|
||||
);
|
||||
}
|
||||
@@ -791,12 +893,18 @@ class SongsCompanion extends UpdateCompanion<Song> {
|
||||
if (categoriesEdited.present) {
|
||||
map['categories_edited'] = Variable<bool>(categoriesEdited.value);
|
||||
}
|
||||
if (metadataEdited.present) {
|
||||
map['metadata_edited'] = Variable<bool>(metadataEdited.value);
|
||||
}
|
||||
if (lyrics.present) {
|
||||
map['lyrics'] = Variable<String>(lyrics.value);
|
||||
}
|
||||
if (gainDb.present) {
|
||||
map['gain_db'] = Variable<double>(gainDb.value);
|
||||
}
|
||||
if (cloudId.present) {
|
||||
map['cloud_id'] = Variable<String>(cloudId.value);
|
||||
}
|
||||
if (rowid.present) {
|
||||
map['rowid'] = Variable<int>(rowid.value);
|
||||
}
|
||||
@@ -818,8 +926,10 @@ class SongsCompanion extends UpdateCompanion<Song> {
|
||||
..write('deleted: $deleted, ')
|
||||
..write('playCount: $playCount, ')
|
||||
..write('categoriesEdited: $categoriesEdited, ')
|
||||
..write('metadataEdited: $metadataEdited, ')
|
||||
..write('lyrics: $lyrics, ')
|
||||
..write('gainDb: $gainDb, ')
|
||||
..write('cloudId: $cloudId, ')
|
||||
..write('rowid: $rowid')
|
||||
..write(')'))
|
||||
.toString();
|
||||
@@ -2698,8 +2808,10 @@ typedef $$SongsTableCreateCompanionBuilder =
|
||||
Value<bool> deleted,
|
||||
Value<int> playCount,
|
||||
Value<bool> categoriesEdited,
|
||||
Value<bool> metadataEdited,
|
||||
Value<String?> lyrics,
|
||||
Value<double?> gainDb,
|
||||
Value<String?> cloudId,
|
||||
Value<int> rowid,
|
||||
});
|
||||
typedef $$SongsTableUpdateCompanionBuilder =
|
||||
@@ -2716,8 +2828,10 @@ typedef $$SongsTableUpdateCompanionBuilder =
|
||||
Value<bool> deleted,
|
||||
Value<int> playCount,
|
||||
Value<bool> categoriesEdited,
|
||||
Value<bool> metadataEdited,
|
||||
Value<String?> lyrics,
|
||||
Value<double?> gainDb,
|
||||
Value<String?> cloudId,
|
||||
Value<int> rowid,
|
||||
});
|
||||
|
||||
@@ -2868,6 +2982,11 @@ class $$SongsTableFilterComposer extends Composer<_$MeloDb, $SongsTable> {
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<bool> get metadataEdited => $composableBuilder(
|
||||
column: $table.metadataEdited,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get lyrics => $composableBuilder(
|
||||
column: $table.lyrics,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
@@ -2878,6 +2997,11 @@ class $$SongsTableFilterComposer extends Composer<_$MeloDb, $SongsTable> {
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get cloudId => $composableBuilder(
|
||||
column: $table.cloudId,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
Expression<bool> playlistSongsRefs(
|
||||
Expression<bool> Function($$PlaylistSongsTableFilterComposer f) f,
|
||||
) {
|
||||
@@ -3047,6 +3171,11 @@ class $$SongsTableOrderingComposer extends Composer<_$MeloDb, $SongsTable> {
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<bool> get metadataEdited => $composableBuilder(
|
||||
column: $table.metadataEdited,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get lyrics => $composableBuilder(
|
||||
column: $table.lyrics,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
@@ -3056,6 +3185,11 @@ class $$SongsTableOrderingComposer extends Composer<_$MeloDb, $SongsTable> {
|
||||
column: $table.gainDb,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get cloudId => $composableBuilder(
|
||||
column: $table.cloudId,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$SongsTableAnnotationComposer extends Composer<_$MeloDb, $SongsTable> {
|
||||
@@ -3110,12 +3244,20 @@ class $$SongsTableAnnotationComposer extends Composer<_$MeloDb, $SongsTable> {
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<bool> get metadataEdited => $composableBuilder(
|
||||
column: $table.metadataEdited,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<String> get lyrics =>
|
||||
$composableBuilder(column: $table.lyrics, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<double> get gainDb =>
|
||||
$composableBuilder(column: $table.gainDb, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get cloudId =>
|
||||
$composableBuilder(column: $table.cloudId, builder: (column) => column);
|
||||
|
||||
Expression<T> playlistSongsRefs<T extends Object>(
|
||||
Expression<T> Function($$PlaylistSongsTableAnnotationComposer a) f,
|
||||
) {
|
||||
@@ -3262,8 +3404,10 @@ class $$SongsTableTableManager
|
||||
Value<bool> deleted = const Value.absent(),
|
||||
Value<int> playCount = const Value.absent(),
|
||||
Value<bool> categoriesEdited = const Value.absent(),
|
||||
Value<bool> metadataEdited = const Value.absent(),
|
||||
Value<String?> lyrics = const Value.absent(),
|
||||
Value<double?> gainDb = const Value.absent(),
|
||||
Value<String?> cloudId = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) => SongsCompanion(
|
||||
id: id,
|
||||
@@ -3278,8 +3422,10 @@ class $$SongsTableTableManager
|
||||
deleted: deleted,
|
||||
playCount: playCount,
|
||||
categoriesEdited: categoriesEdited,
|
||||
metadataEdited: metadataEdited,
|
||||
lyrics: lyrics,
|
||||
gainDb: gainDb,
|
||||
cloudId: cloudId,
|
||||
rowid: rowid,
|
||||
),
|
||||
createCompanionCallback:
|
||||
@@ -3296,8 +3442,10 @@ class $$SongsTableTableManager
|
||||
Value<bool> deleted = const Value.absent(),
|
||||
Value<int> playCount = const Value.absent(),
|
||||
Value<bool> categoriesEdited = const Value.absent(),
|
||||
Value<bool> metadataEdited = const Value.absent(),
|
||||
Value<String?> lyrics = const Value.absent(),
|
||||
Value<double?> gainDb = const Value.absent(),
|
||||
Value<String?> cloudId = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) => SongsCompanion.insert(
|
||||
id: id,
|
||||
@@ -3312,8 +3460,10 @@ class $$SongsTableTableManager
|
||||
deleted: deleted,
|
||||
playCount: playCount,
|
||||
categoriesEdited: categoriesEdited,
|
||||
metadataEdited: metadataEdited,
|
||||
lyrics: lyrics,
|
||||
gainDb: gainDb,
|
||||
cloudId: cloudId,
|
||||
rowid: rowid,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:record/record.dart';
|
||||
|
||||
import '../services/acr_cloud.dart';
|
||||
import '../shared/theme.dart';
|
||||
|
||||
/// Wie lange zugehört wird. Kürzer erkennt ACRCloud oft nicht mehr sicher.
|
||||
const _aufnahmeSekunden = 10;
|
||||
|
||||
/// Musikerkennung: nimmt kurz über das Mikrofon auf und fragt ACRCloud,
|
||||
/// welches Stück gerade läuft.
|
||||
class MusicRecognitionSheet extends StatefulWidget {
|
||||
const MusicRecognitionSheet({super.key});
|
||||
|
||||
static Future<void> show(BuildContext context) {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: MeloTheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (_) => const MusicRecognitionSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<MusicRecognitionSheet> createState() => _MusicRecognitionSheetState();
|
||||
}
|
||||
|
||||
/// Was das Sheet gerade anzeigt.
|
||||
enum _Phase { start, aufnahme, suche, ergebnis, fehler }
|
||||
|
||||
class _MusicRecognitionSheetState extends State<MusicRecognitionSheet> {
|
||||
final _zugang = AcrZugang();
|
||||
AudioRecorder? _rekorder;
|
||||
|
||||
_Phase _phase = _Phase.start;
|
||||
int _restSekunden = _aufnahmeSekunden;
|
||||
AcrTreffer? _treffer;
|
||||
String? _fehler;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_starte();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_rekorder?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Zugangsdaten holen (notfalls erfragen) und dann zuhören.
|
||||
Future<void> _starte() async {
|
||||
await _zugang.laden();
|
||||
if (!mounted) return;
|
||||
if (!_zugang.istKonfiguriert) {
|
||||
final gespeichert = await _frageZugang();
|
||||
if (!mounted) return;
|
||||
if (!gespeichert) {
|
||||
Navigator.of(context).pop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
await _hoereZu();
|
||||
}
|
||||
|
||||
/// Dialog für die beiden ACRCloud-Schlüssel. `true`, wenn gespeichert wurde.
|
||||
Future<bool> _frageZugang() async {
|
||||
final accessCtrl = TextEditingController();
|
||||
final secretCtrl = TextEditingController();
|
||||
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: MeloTheme.surface,
|
||||
title: const Text('ACRCloud-Zugang',
|
||||
style: TextStyle(color: Colors.white, fontSize: 16)),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('ACRCloud-Zugang (steht in deinen Notizen)',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 12)),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: accessCtrl,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Access Key',
|
||||
labelStyle: TextStyle(color: Colors.grey),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: secretCtrl,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Secret Key',
|
||||
labelStyle: TextStyle(color: Colors.grey),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: MeloTheme.red),
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Speichern'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final access = accessCtrl.text.trim();
|
||||
final secret = secretCtrl.text.trim();
|
||||
accessCtrl.dispose();
|
||||
secretCtrl.dispose();
|
||||
if (ok != true || access.isEmpty || secret.isEmpty) return false;
|
||||
|
||||
await _zugang.speichern(access, secret);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Nimmt [_aufnahmeSekunden] Sekunden auf und schickt sie zur Erkennung.
|
||||
Future<void> _hoereZu() async {
|
||||
setState(() {
|
||||
_phase = _Phase.aufnahme;
|
||||
_restSekunden = _aufnahmeSekunden;
|
||||
_treffer = null;
|
||||
_fehler = null;
|
||||
});
|
||||
|
||||
final rekorder = _rekorder = AudioRecorder();
|
||||
try {
|
||||
if (!await rekorder.hasPermission()) {
|
||||
_scheitere('Ohne Mikrofon-Erlaubnis kann ich nicht zuhören');
|
||||
return;
|
||||
}
|
||||
|
||||
final ordner = await getTemporaryDirectory();
|
||||
final pfad = '${ordner.path}/melo_erkennung.wav';
|
||||
await rekorder.start(
|
||||
const RecordConfig(
|
||||
encoder: AudioEncoder.wav,
|
||||
sampleRate: 8000,
|
||||
numChannels: 1,
|
||||
),
|
||||
path: pfad,
|
||||
);
|
||||
|
||||
for (var rest = _aufnahmeSekunden; rest > 0; rest--) {
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
if (!mounted) return;
|
||||
setState(() => _restSekunden = rest - 1);
|
||||
}
|
||||
|
||||
await rekorder.stop();
|
||||
await rekorder.dispose();
|
||||
_rekorder = null;
|
||||
if (!mounted) return;
|
||||
setState(() => _phase = _Phase.suche);
|
||||
|
||||
final aufnahme = await File(pfad).readAsBytes();
|
||||
final dienst = AcrCloudService(
|
||||
accessKey: _zugang.accessKey!,
|
||||
secretKey: _zugang.secretKey!,
|
||||
);
|
||||
final treffer = await dienst.erkenne(aufnahme);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_treffer = treffer;
|
||||
_phase = _Phase.ergebnis;
|
||||
});
|
||||
} on AcrCloudException catch (e) {
|
||||
_scheitere(e.nachricht);
|
||||
} catch (e) {
|
||||
debugPrint('Musikerkennung fehlgeschlagen: $e');
|
||||
_scheitere('Die Aufnahme hat nicht geklappt');
|
||||
}
|
||||
}
|
||||
|
||||
void _scheitere(String text) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_fehler = text;
|
||||
_phase = _Phase.fehler;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Musik erkennen',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 24),
|
||||
_inhalt(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _inhalt() => switch (_phase) {
|
||||
_Phase.start => const CircularProgressIndicator(color: MeloTheme.red),
|
||||
_Phase.aufnahme => Column(
|
||||
children: [
|
||||
const Icon(Icons.mic, color: MeloTheme.red, size: 64),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Ich höre zu …',
|
||||
style: TextStyle(color: Colors.white70)),
|
||||
const SizedBox(height: 8),
|
||||
Text('$_restSekunden',
|
||||
style: const TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: MeloTheme.red)),
|
||||
],
|
||||
),
|
||||
_Phase.suche => const Column(
|
||||
children: [
|
||||
CircularProgressIndicator(color: MeloTheme.red),
|
||||
SizedBox(height: 16),
|
||||
Text('Ich suche den Titel …',
|
||||
style: TextStyle(color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
_Phase.ergebnis => _treffer == null
|
||||
? _meldung(Icons.search_off,
|
||||
'Nicht erkannt — probier es nochmal näher an der Musik')
|
||||
: Column(
|
||||
children: [
|
||||
const Icon(Icons.music_note, color: MeloTheme.red, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(_treffer!.titel,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 22, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 6),
|
||||
Text(_treffer!.kuenstler,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 16, color: Colors.white70)),
|
||||
if (_treffer!.album.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(_treffer!.album,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: Colors.white38)),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
_nochmalKnopf(),
|
||||
],
|
||||
),
|
||||
_Phase.fehler =>
|
||||
_meldung(Icons.error_outline, _fehler ?? 'Etwas ist schiefgelaufen'),
|
||||
};
|
||||
|
||||
Widget _meldung(IconData icon, String text) => Column(
|
||||
children: [
|
||||
Icon(icon, color: MeloTheme.red, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(text,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70)),
|
||||
const SizedBox(height: 20),
|
||||
_nochmalKnopf(),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _nochmalKnopf() => FilledButton.icon(
|
||||
style: FilledButton.styleFrom(backgroundColor: MeloTheme.red),
|
||||
onPressed: _hoereZu,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Nochmal'),
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import 'album_list.dart';
|
||||
import 'artist_list.dart';
|
||||
import 'database.dart';
|
||||
import 'library_service.dart';
|
||||
import 'music_recognition_sheet.dart';
|
||||
import 'permissions.dart';
|
||||
import 'song_list.dart';
|
||||
|
||||
@@ -163,9 +164,7 @@ class _Header extends StatelessWidget {
|
||||
IconButton(
|
||||
tooltip: 'Musik erkennen',
|
||||
icon: const Icon(Icons.help_outline),
|
||||
onPressed: () => ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Musikerkennung kommt später')),
|
||||
),
|
||||
onPressed: () => MusicRecognitionSheet.show(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -87,12 +87,17 @@ Future<int> scanFolders(
|
||||
}
|
||||
}
|
||||
|
||||
// Von Hand korrigierte Metadaten überleben jeden weiteren Scan —
|
||||
// sonst holt der nächste Durchlauf die falschen Tags der Datei zurück.
|
||||
final behalten = prev?.metadataEdited == true;
|
||||
|
||||
companions.add(SongsCompanion.insert(
|
||||
id: id,
|
||||
path: file.path,
|
||||
title: title,
|
||||
artist: Value(meta?.artist),
|
||||
album: Value(meta?.album),
|
||||
title: behalten ? prev!.title : title,
|
||||
artist: Value(behalten ? prev!.artist : meta?.artist),
|
||||
album: Value(behalten ? prev!.album : meta?.album),
|
||||
metadataEdited: Value(behalten),
|
||||
durationMs: Value(meta?.duration?.inMilliseconds),
|
||||
coverPath: Value(coverPath),
|
||||
dateAddedMs: prev?.dateAddedMs ?? now,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/musicbrainz_service.dart';
|
||||
import '../settings/app_settings.dart';
|
||||
import '../shared/cover.dart';
|
||||
import '../shared/theme.dart';
|
||||
@@ -155,12 +157,119 @@ class _ExpertSection extends StatelessWidget {
|
||||
_Row('Format', _extension(song.path)),
|
||||
_Row('Dateigröße', _fileSize(song.path)),
|
||||
_Row('Pfad', song.path),
|
||||
_OnlineLookup(song: song),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Holt zum Song passende Metadaten von MusicBrainz. Ein Tipp auf einen
|
||||
/// Vorschlag übernimmt Titel, Künstler und Album — gespeichert wird über
|
||||
/// [MeloDb.upsertSongs], denselben Weg, den auch der Scan nimmt.
|
||||
class _OnlineLookup extends StatefulWidget {
|
||||
const _OnlineLookup({required this.song});
|
||||
final Song song;
|
||||
|
||||
@override
|
||||
State<_OnlineLookup> createState() => _OnlineLookupState();
|
||||
}
|
||||
|
||||
class _OnlineLookupState extends State<_OnlineLookup> {
|
||||
final _dienst = MusicBrainzService();
|
||||
bool _laeuft = false;
|
||||
List<MbVorschlag>? _vorschlaege;
|
||||
|
||||
Future<void> _nachschlagen() async {
|
||||
setState(() => _laeuft = true);
|
||||
try {
|
||||
final gefunden = await _dienst.suche(
|
||||
titel: widget.song.title,
|
||||
kuenstler: widget.song.artist,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_vorschlaege = gefunden;
|
||||
_laeuft = false;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('MusicBrainz nicht erreichbar: $e');
|
||||
if (!mounted) return;
|
||||
setState(() => _laeuft = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('MusicBrainz nicht erreichbar')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Übernimmt [vorschlag]; leere Angaben lassen den bisherigen Wert stehen.
|
||||
Future<void> _uebernehmen(MbVorschlag vorschlag) async {
|
||||
final song = widget.song;
|
||||
final db = context.read<MeloDb>();
|
||||
final navigator = Navigator.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
await db.upsertSongs([metadatenUebernahme(song, vorschlag)]);
|
||||
if (!mounted) return;
|
||||
|
||||
// Das Sheet zeigt eine Kopie des Songs — geschlossen wirkt die Änderung
|
||||
// sofort in der Liste darunter.
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('Metadaten übernommen')),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final vorschlaege = _vorschlaege;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.travel_explore, size: 16),
|
||||
label: const Text('Online nachschlagen'),
|
||||
onPressed: _laeuft ? null : _nachschlagen,
|
||||
),
|
||||
if (_laeuft) ...[
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: MeloTheme.red),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (vorschlaege != null && vorschlaege.isEmpty)
|
||||
const Text('Nichts gefunden',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 13)),
|
||||
if (vorschlaege != null && vorschlaege.isNotEmpty) ...[
|
||||
const Text('Tippen übernimmt Titel, Künstler und Album',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 12)),
|
||||
for (final vorschlag in vorschlaege)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
title: Text(vorschlag.titel,
|
||||
style: const TextStyle(fontSize: 14)),
|
||||
subtitle: Text(
|
||||
[vorschlag.kuenstler, vorschlag.album]
|
||||
.where((t) => t.isNotEmpty)
|
||||
.join(' — '),
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 12),
|
||||
),
|
||||
onTap: () => _uebernehmen(vorschlag),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Row extends StatelessWidget {
|
||||
const _Row(this.label, this.value);
|
||||
final String label;
|
||||
@@ -325,3 +434,23 @@ class _CategoryEditorState extends State<_CategoryEditor> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Baut den Datenbank-Eintrag für einen übernommenen Online-Vorschlag.
|
||||
/// Leere Angaben lassen den bisherigen Wert stehen.
|
||||
///
|
||||
/// Setzt [Songs.metadataEdited] — ohne diese Markierung holt der nächste
|
||||
/// Bibliotheks-Scan die falschen Tags der Datei zurück und die Korrektur
|
||||
/// wäre wieder weg.
|
||||
SongsCompanion metadatenUebernahme(Song song, MbVorschlag vorschlag) {
|
||||
return SongsCompanion.insert(
|
||||
id: song.id,
|
||||
path: song.path,
|
||||
title: vorschlag.titel.isEmpty ? song.title : vorschlag.titel,
|
||||
artist:
|
||||
Value(vorschlag.kuenstler.isEmpty ? song.artist : vorschlag.kuenstler),
|
||||
album: Value(vorschlag.album.isEmpty ? song.album : vorschlag.album),
|
||||
metadataEdited: const Value(true),
|
||||
dateAddedMs: song.dateAddedMs,
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
}
|
||||
|
||||
+17
-3
@@ -19,7 +19,9 @@ import 'player/audio_handler.dart';
|
||||
import 'player/mini_player.dart';
|
||||
import 'services/baka_auth.dart';
|
||||
import 'services/logger_service.dart';
|
||||
import 'services/melo_cloud_service.dart';
|
||||
import 'services/offline_mode.dart';
|
||||
import 'services/sync_service.dart';
|
||||
import 'services/yt_download_service.dart';
|
||||
import 'settings/app_settings.dart';
|
||||
import 'shared/theme.dart';
|
||||
@@ -34,6 +36,7 @@ late final AppSettings _settings;
|
||||
late final AudioEffects _effects;
|
||||
late final BakaAuth _bakaAuth;
|
||||
late final YtDownloadService _ytDownload;
|
||||
late final SyncService _sync;
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -60,6 +63,11 @@ Future<void> main() async {
|
||||
_bakaAuth = BakaAuth();
|
||||
await _bakaAuth.laden();
|
||||
_ytDownload = YtDownloadService(auth: _bakaAuth);
|
||||
_sync = SyncService(
|
||||
db: _db,
|
||||
cloud: MeloCloudService(auth: _bakaAuth),
|
||||
);
|
||||
await _sync.laden();
|
||||
// Die Audio-Session steht erst beim Abspielen fest und kann wechseln —
|
||||
// die Klangeffekte müssen dann neu daran gehängt werden.
|
||||
_handler.androidAudioSessionIdStream.listen((id) {
|
||||
@@ -93,6 +101,7 @@ class MeloApp extends StatelessWidget {
|
||||
ChangeNotifierProvider<AudioEffects>.value(value: _effects),
|
||||
ChangeNotifierProvider<BakaAuth>.value(value: _bakaAuth),
|
||||
ChangeNotifierProvider<YtDownloadService>.value(value: _ytDownload),
|
||||
ChangeNotifierProvider<SyncService>.value(value: _sync),
|
||||
],
|
||||
child: MaterialApp(
|
||||
title: 'Melo',
|
||||
@@ -128,6 +137,9 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||
scanne: lib.rescan,
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
// Beim Start einmal mit dem Server abgleichen — neue Titel von anderen
|
||||
// Geräten sind dann sofort da.
|
||||
context.read<SyncService>().automatisch();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -138,9 +150,11 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
// Zurück in der App: nachsehen, ob inzwischen Musik dazugekommen ist.
|
||||
if (state == AppLifecycleState.resumed && Platform.isAndroid) {
|
||||
_autoScan.pruefe();
|
||||
// Zurück in der App: nachsehen, ob inzwischen Musik dazugekommen ist —
|
||||
// auf dem Gerät wie am Server.
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
if (Platform.isAndroid) _autoScan.pruefe();
|
||||
context.read<SyncService>().automatisch();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,30 @@ int playNextIndex({required int? currentIndex, required int queueLength}) {
|
||||
return (currentIndex + 1).clamp(0, queueLength);
|
||||
}
|
||||
|
||||
/// Navidrome-Song-ID eines Warteschlangen-Eintrags, oder `null` bei einem
|
||||
/// Titel vom Gerät.
|
||||
///
|
||||
/// [MediaItem.id] ist immer schon eine abspielbare URI — `file://` beim Gerät,
|
||||
/// die fertige Stream-Adresse beim Server — und taugt deshalb NICHT als
|
||||
/// Server-ID. Früher wurde sie genau dafür benutzt: die App fragte den Server
|
||||
/// nach einem Titel namens `file:///storage/...`, bekam eine Fehlerantwort und
|
||||
/// gab die an den Player weiter. Lokale Lieder waren dadurch unabspielbar
|
||||
/// ("(0) Source error").
|
||||
String? navidromeIdOf(MediaItem item) => item.extras?['navidromeId'] as String?;
|
||||
|
||||
/// UUID des Titels in der lokalen Bibliothek, oder `null` bei einem Titel,
|
||||
/// der nur am Server liegt. Trägt Historie, Wiedergabezähler und Resume.
|
||||
String? songIdOf(MediaItem item) => item.extras?['songId'] as String?;
|
||||
|
||||
/// Ob für [item] im Server-Cache nachgesehen wird. Titel vom Gerät liegen
|
||||
/// bereits als Datei vor — für sie darf der Server nie befragt werden.
|
||||
bool nutztServerCache(MediaItem item) => navidromeIdOf(item) != null;
|
||||
|
||||
/// Abspielbare Quelle eines Warteschlangen-Eintrags. [cacheTreffer] ist der
|
||||
/// Pfad einer bereits vollständig zwischengespeicherten Datei, sonst `null`.
|
||||
Uri quelleFuer(MediaItem item, {String? cacheTreffer}) =>
|
||||
cacheTreffer != null ? Uri.file(cacheTreffer) : Uri.parse(item.id);
|
||||
|
||||
/// Kern der Wiedergabe: kapselt just_audio hinter audio_service,
|
||||
/// damit Hintergrund-Wiedergabe + Lockscreen/Notification funktionieren.
|
||||
class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
||||
@@ -58,6 +82,9 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
||||
MeloAudioHandler({required this.db}) {
|
||||
_cache = CacheManager();
|
||||
_cache.init();
|
||||
// Einmalig beim Start — nicht bei jeder Wiedergabe, sonst verzögert ein
|
||||
// Speicherzugriff jeden Titelstart. Gebraucht nur für Server-Titel.
|
||||
unawaited(_nav.ladeGespeicherteZugangsdaten());
|
||||
sleepTimer = SleepTimer(onElapsed: pause);
|
||||
// just_audio-Events → audio_service PlaybackState
|
||||
_player.playbackEventStream.map(_transformEvent).pipe(playbackState);
|
||||
@@ -65,13 +92,16 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
||||
// Wiedergabeposition alle ~5s persistieren, solange aktiv abgespielt wird.
|
||||
// Synche auch zum Server wenn verbunden.
|
||||
_positionRecordTimer = Timer.periodic(const Duration(seconds: 5), (_) {
|
||||
final songId = mediaItem.value?.extras?['songId'] as String?;
|
||||
if (_player.playing && songId != null) {
|
||||
final posMs = _player.position.inMilliseconds;
|
||||
db.recordPlayback(songId, posMs);
|
||||
if (_nav.istVerbunden) {
|
||||
_nav.scrobble(songId, (posMs ~/ 1000).toInt());
|
||||
}
|
||||
final item = mediaItem.value;
|
||||
if (!_player.playing || item == null) return;
|
||||
final posMs = _player.position.inMilliseconds;
|
||||
final songId = songIdOf(item);
|
||||
if (songId != null) db.recordPlayback(songId, posMs);
|
||||
// Nur echte Server-Titel scrobbeln — eine lokale UUID kennt der
|
||||
// Server nicht und jeder Aufruf wäre eine Anfrage ins Leere.
|
||||
final navId = navidromeIdOf(item);
|
||||
if (navId != null && _nav.istVerbunden) {
|
||||
_nav.scrobble(navId, posMs ~/ 1000);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -84,7 +114,7 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
||||
_applyGain(q[index]);
|
||||
if (shouldCountPlay(index, _lastCountedIndex)) {
|
||||
_lastCountedIndex = index;
|
||||
final songId = q[index].extras?['songId'] as String?;
|
||||
final songId = songIdOf(q[index]);
|
||||
if (songId != null) db.incrementPlayCount(songId);
|
||||
}
|
||||
}
|
||||
@@ -130,57 +160,69 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
||||
_player.androidAudioSessionIdStream;
|
||||
|
||||
/// Ersetzt die Warteschlange und startet ab [startIndex].
|
||||
/// Auto-Caching: Streame von Server + speichere lokal gleichzeitig.
|
||||
///
|
||||
/// Baut die Quellen ohne einen einzigen Netzzugriff: [MediaItem.id] ist
|
||||
/// bereits abspielbar. Früher lud diese Schleife die *ganze* Warteschlange
|
||||
/// vorab vom Server herunter — bei einer Bibliothek mit hunderten Titeln
|
||||
/// begann die Wiedergabe deshalb nie, und ein zweiter Tipp brach den
|
||||
/// laufenden Ladevorgang ab ("Loading interrupted").
|
||||
Future<void> loadPlaylist(List<MediaItem> items, {int startIndex = 0}) async {
|
||||
queue.add(items);
|
||||
// Neue Warteschlange: der erste Titel soll wieder zählen, auch wenn er
|
||||
// denselben Index wie der zuletzt gezählte hat.
|
||||
_lastCountedIndex = null;
|
||||
await _nav.ladeGespeicherteZugangsdaten();
|
||||
|
||||
final sources = <AudioSource>[];
|
||||
for (final item in items) {
|
||||
Uri sourceUri = Uri.parse(item.id);
|
||||
|
||||
if (_nav.istVerbunden) {
|
||||
final cachedUri = await _nav.streamAndCacheToLocal(item.id, _cache);
|
||||
if (cachedUri != null) {
|
||||
sourceUri = cachedUri;
|
||||
}
|
||||
}
|
||||
|
||||
sources.add(AudioSource.uri(sourceUri, tag: item));
|
||||
// Ein vollständig zwischengespeicherter Server-Titel läuft offline.
|
||||
final navId = navidromeIdOf(item);
|
||||
final treffer = navId == null
|
||||
? null
|
||||
: await _cache.getCachedFile(NavidromeService.cacheSchluessel(navId));
|
||||
sources.add(AudioSource.uri(
|
||||
quelleFuer(item, cacheTreffer: treffer?.path),
|
||||
tag: item,
|
||||
));
|
||||
}
|
||||
|
||||
await _player.setAudioSources(sources, initialIndex: startIndex);
|
||||
|
||||
// Bei bekannter letzter Position an dieser Stelle fortsetzen,
|
||||
// statt immer von vorne zu beginnen. Priorisiert Server-Position über lokal.
|
||||
// statt immer von vorne zu beginnen.
|
||||
final item = items[startIndex];
|
||||
final songId = item.extras?['songId'] as String?;
|
||||
if (songId != null) {
|
||||
int? resumeMs;
|
||||
|
||||
if (_nav.istVerbunden) {
|
||||
final serverMs = await _nav.getBookmark(songId);
|
||||
if (serverMs != null && shouldResumeAt(serverMs, item.duration)) {
|
||||
resumeMs = serverMs;
|
||||
}
|
||||
}
|
||||
|
||||
if (resumeMs == null) {
|
||||
final localMs = await db.lastPosition(songId);
|
||||
if (localMs != null && shouldResumeAt(localMs, item.duration)) {
|
||||
resumeMs = localMs;
|
||||
}
|
||||
}
|
||||
|
||||
if (resumeMs != null) {
|
||||
await _player.seek(Duration(milliseconds: resumeMs), index: startIndex);
|
||||
}
|
||||
final resumeMs = await _fortsetzPosition(item);
|
||||
if (resumeMs != null) {
|
||||
await _player.seek(Duration(milliseconds: resumeMs), index: startIndex);
|
||||
}
|
||||
|
||||
await play();
|
||||
|
||||
// Server-Titel im Hintergrund ablegen — das darf den Start nie aufhalten.
|
||||
final navId = navidromeIdOf(item);
|
||||
if (navId != null) {
|
||||
unawaited(_nav.ladeInCache(navId, Uri.parse(item.id), _cache));
|
||||
}
|
||||
}
|
||||
|
||||
/// Position, an der [item] fortgesetzt wird, oder `null` für den Anfang.
|
||||
/// Der Server zählt nur bei Server-Titeln — für Titel vom Gerät gilt
|
||||
/// allein die lokale Historie.
|
||||
Future<int?> _fortsetzPosition(MediaItem item) async {
|
||||
final navId = navidromeIdOf(item);
|
||||
if (navId != null && _nav.istVerbunden) {
|
||||
final serverMs = await _nav.getBookmark(navId);
|
||||
if (serverMs != null && shouldResumeAt(serverMs, item.duration)) {
|
||||
return serverMs;
|
||||
}
|
||||
}
|
||||
final songId = songIdOf(item);
|
||||
if (songId != null) {
|
||||
final localMs = await db.lastPosition(songId);
|
||||
if (localMs != null && shouldResumeAt(localMs, item.duration)) {
|
||||
return localMs;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Hängt [item] hinten an die Warteschlange an, ohne die laufende
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'baka_auth.dart';
|
||||
|
||||
/// Ein von ACRCloud erkanntes Stück.
|
||||
class AcrTreffer {
|
||||
const AcrTreffer({
|
||||
required this.titel,
|
||||
required this.kuenstler,
|
||||
required this.album,
|
||||
});
|
||||
|
||||
final String titel;
|
||||
final String kuenstler;
|
||||
final String album;
|
||||
}
|
||||
|
||||
/// Fehler, den die Erkennung selbst meldet (falscher Schlüssel, Limit
|
||||
/// erreicht, kein Netz …) — mit einem Text, der dem Nutzer gezeigt werden darf.
|
||||
class AcrCloudException implements Exception {
|
||||
const AcrCloudException(this.nachricht);
|
||||
|
||||
final String nachricht;
|
||||
|
||||
@override
|
||||
String toString() => nachricht;
|
||||
}
|
||||
|
||||
/// Musikerkennung über ACRCloud: eine kurze Aufnahme hochladen und den
|
||||
/// erkannten Titel zurückbekommen.
|
||||
class AcrCloudService {
|
||||
AcrCloudService({
|
||||
required this.accessKey,
|
||||
required this.secretKey,
|
||||
this.host = standardHost,
|
||||
http.Client? client,
|
||||
}) : _client = client ?? http.Client();
|
||||
|
||||
static const standardHost = 'identify-eu-west-1.acrcloud.com';
|
||||
|
||||
final String accessKey;
|
||||
final String secretKey;
|
||||
final String host;
|
||||
final http.Client _client;
|
||||
|
||||
/// HMAC-SHA1 über die von ACRCloud vorgeschriebene Zeichenkette,
|
||||
/// base64-codiert. Muss zum mitgeschickten [timestamp] passen.
|
||||
static String signatur({
|
||||
required String accessKey,
|
||||
required String secretKey,
|
||||
required int timestamp,
|
||||
}) {
|
||||
final zeichenkette = 'POST\n/v1/identify\n$accessKey\naudio\n1\n$timestamp';
|
||||
final hmac = Hmac(sha1, utf8.encode(secretKey));
|
||||
return base64.encode(hmac.convert(utf8.encode(zeichenkette)).bytes);
|
||||
}
|
||||
|
||||
/// Wertet die Antwort aus: Treffer, `null` (nichts erkannt) oder Ausnahme.
|
||||
static AcrTreffer? parseAntwort(String body) {
|
||||
final Map<String, dynamic> daten;
|
||||
try {
|
||||
daten = jsonDecode(body) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
throw const AcrCloudException('Musikerkennung antwortet unverständlich');
|
||||
}
|
||||
|
||||
final status = daten['status'] as Map<String, dynamic>?;
|
||||
final code = status?['code'] as int?;
|
||||
// 1001 = "No result" — das ist kein Fehler, nur kein Treffer.
|
||||
if (code == 1001) return null;
|
||||
if (code != 0) {
|
||||
throw AcrCloudException(
|
||||
status?['msg'] as String? ?? 'Musikerkennung fehlgeschlagen ($code)');
|
||||
}
|
||||
|
||||
final metadaten = daten['metadata'] as Map<String, dynamic>?;
|
||||
final musik = metadaten?['music'] as List<dynamic>?;
|
||||
if (musik == null || musik.isEmpty) return null;
|
||||
|
||||
final erster = musik.first as Map<String, dynamic>;
|
||||
final kuenstler = erster['artists'] as List<dynamic>?;
|
||||
return AcrTreffer(
|
||||
titel: erster['title'] as String? ?? '',
|
||||
kuenstler: kuenstler == null || kuenstler.isEmpty
|
||||
? ''
|
||||
: (kuenstler.first as Map<String, dynamic>)['name'] as String? ?? '',
|
||||
album: (erster['album'] as Map<String, dynamic>?)?['name'] as String? ??
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
/// Schickt die Aufnahme [sample] (WAV) zur Erkennung.
|
||||
/// Gibt bei Erfolg den Treffer zurück, `null` wenn nichts erkannt wurde.
|
||||
Future<AcrTreffer?> erkenne(List<int> sample) async {
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final anfrage =
|
||||
http.MultipartRequest('POST', Uri.https(host, '/v1/identify'))
|
||||
..fields['access_key'] = accessKey
|
||||
..fields['data_type'] = 'audio'
|
||||
..fields['signature_version'] = '1'
|
||||
..fields['timestamp'] = '$timestamp'
|
||||
..fields['signature'] = signatur(
|
||||
accessKey: accessKey, secretKey: secretKey, timestamp: timestamp)
|
||||
..fields['sample_bytes'] = '${sample.length}'
|
||||
..files.add(http.MultipartFile.fromBytes('sample', sample,
|
||||
filename: 'sample.wav'));
|
||||
|
||||
final http.Response antwort;
|
||||
try {
|
||||
final gestreamt =
|
||||
await _client.send(anfrage).timeout(const Duration(seconds: 30));
|
||||
antwort = await http.Response.fromStream(gestreamt);
|
||||
} catch (e) {
|
||||
debugPrint('Musikerkennung nicht erreichbar: $e');
|
||||
throw const AcrCloudException('Keine Verbindung zur Musikerkennung');
|
||||
}
|
||||
|
||||
return parseAntwort(antwort.body);
|
||||
}
|
||||
}
|
||||
|
||||
/// Die ACRCloud-Zugangsdaten. Sie stehen nirgends im Code — der Nutzer gibt
|
||||
/// sie einmalig ein, danach liegen sie im verschlüsselten Gerätespeicher.
|
||||
class AcrZugang {
|
||||
AcrZugang({TokenSpeicher? speicher})
|
||||
: _speicher = speicher ?? const SicherenSpeicher();
|
||||
|
||||
static const _accessKeyKey = 'acr_access_key';
|
||||
static const _secretKeyKey = 'acr_secret_key';
|
||||
|
||||
final TokenSpeicher _speicher;
|
||||
|
||||
String? accessKey;
|
||||
String? secretKey;
|
||||
|
||||
bool get istKonfiguriert =>
|
||||
(accessKey?.isNotEmpty ?? false) && (secretKey?.isNotEmpty ?? false);
|
||||
|
||||
Future<void> laden() async {
|
||||
accessKey = await _speicher.lesen(_accessKeyKey);
|
||||
secretKey = await _speicher.lesen(_secretKeyKey);
|
||||
}
|
||||
|
||||
Future<void> speichern(String accessKey, String secretKey) async {
|
||||
this.accessKey = accessKey;
|
||||
this.secretKey = secretKey;
|
||||
await _speicher.schreiben(_accessKeyKey, accessKey);
|
||||
await _speicher.schreiben(_secretKeyKey, secretKey);
|
||||
}
|
||||
}
|
||||
@@ -14,17 +14,21 @@ class MediaStore {
|
||||
final MethodChannel channel;
|
||||
|
||||
/// Verschiebt [quellPfad] nach `Music/Melo` und meldet die Datei dem
|
||||
/// MediaStore. Gibt den neuen Pfad zurück, oder `null` wenn es nicht klappt.
|
||||
/// MediaStore. [volume] wählt den Datenträger (siehe [speicherOrte]);
|
||||
/// ohne Angabe nimmt Android den internen Speicher.
|
||||
/// Gibt den neuen Pfad zurück, oder `null` wenn es nicht klappt.
|
||||
Future<String?> veroeffentliche({
|
||||
required String quellPfad,
|
||||
required String titel,
|
||||
String? kuenstler,
|
||||
String? volume,
|
||||
}) async {
|
||||
try {
|
||||
return await channel.invokeMethod<String>('publishAudio', {
|
||||
'sourcePath': quellPfad,
|
||||
'title': titel,
|
||||
'artist': kuenstler,
|
||||
'volume': volume,
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('MediaStore-Eintrag fehlgeschlagen: ${e.message}');
|
||||
@@ -34,4 +38,39 @@ class MediaStore {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Alle Datenträger, auf denen Musik landen kann — interner Speicher und,
|
||||
/// falls eingelegt, SD-Karten.
|
||||
Future<List<SpeicherOrt>> speicherOrte() async {
|
||||
try {
|
||||
final roh =
|
||||
await channel.invokeListMethod<Map<Object?, Object?>>('listVolumes');
|
||||
if (roh == null || roh.isEmpty) return const [_intern];
|
||||
return [
|
||||
for (final eintrag in roh)
|
||||
SpeicherOrt(
|
||||
name: eintrag['name'] as String,
|
||||
beschreibung: eintrag['beschreibung'] as String,
|
||||
),
|
||||
];
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('Speicherorte nicht lesbar: ${e.message}');
|
||||
return const [_intern];
|
||||
} on MissingPluginException {
|
||||
// Desktop/Tests: dort gibt es nur den einen Ordner.
|
||||
return const [_intern];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Datenträger für heruntergeladene Musik. [name] ist der MediaStore-Name
|
||||
/// des Datenträgers, [beschreibung] das, was der Nutzer liest.
|
||||
class SpeicherOrt {
|
||||
const SpeicherOrt({required this.name, required this.beschreibung});
|
||||
|
||||
final String name;
|
||||
final String beschreibung;
|
||||
}
|
||||
|
||||
const _intern =
|
||||
SpeicherOrt(name: 'external_primary', beschreibung: 'Interner Speicher');
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'baka_auth.dart';
|
||||
import 'logger_service.dart';
|
||||
|
||||
/// Ein Titel, wie ihn die Melo-Cloud kennt.
|
||||
///
|
||||
/// [geloescht] markiert einen Grabstein: der Server liefert gelöschte Titel
|
||||
/// bewusst weiter mit, damit die App die Löschung nachziehen kann, statt den
|
||||
/// Titel beim nächsten Abgleich wieder hochzuladen.
|
||||
class CloudSong {
|
||||
const CloudSong({
|
||||
required this.id,
|
||||
required this.titel,
|
||||
this.kuenstler = '',
|
||||
this.dauerSekunden = 0,
|
||||
this.groesse = 0,
|
||||
this.geloescht = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String titel;
|
||||
final String kuenstler;
|
||||
final int dauerSekunden;
|
||||
final int groesse;
|
||||
final bool geloescht;
|
||||
|
||||
factory CloudSong.fromJson(Map<String, dynamic> j) => CloudSong(
|
||||
id: j['id'] as String,
|
||||
titel: (j['title'] as String?)?.trim().isNotEmpty == true
|
||||
? j['title'] as String
|
||||
: 'Unbekannt',
|
||||
kuenstler: j['artist'] as String? ?? '',
|
||||
dauerSekunden: (j['duration'] as num?)?.toInt() ?? 0,
|
||||
groesse: (j['size'] as num?)?.toInt() ?? 0,
|
||||
geloescht: j['deleted'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Ein Wiedergabe-Ereignis, das zum Server gemeldet wird.
|
||||
class CloudVerlauf {
|
||||
const CloudVerlauf({
|
||||
required this.cloudId,
|
||||
required this.gespieltAm,
|
||||
this.positionSekunden = 0,
|
||||
});
|
||||
|
||||
final String cloudId;
|
||||
final DateTime gespieltAm;
|
||||
final int positionSekunden;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'song_id': cloudId,
|
||||
// Der Server erwartet ISO-Zeit ohne Zeitzone (UTC).
|
||||
'played_at': gespieltAm.toUtc().toIso8601String().split('.').first,
|
||||
'position': positionSekunden,
|
||||
};
|
||||
}
|
||||
|
||||
/// Fehler der Melo-Cloud, den die Oberfläche anzeigen darf.
|
||||
class CloudException implements Exception {
|
||||
CloudException(this.message);
|
||||
final String message;
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// Zugriff auf die Melo-Cloud (`cloud.baka-net.de`) — die Gegenstelle für den
|
||||
/// Geräte-Abgleich: Titel hoch- und herunterladen, Löschungen, Favoriten und
|
||||
/// Wiedergabe-Verlauf.
|
||||
///
|
||||
/// Bewusst nicht Navidrome: die Subsonic-API kennt keinen Upload-Endpunkt,
|
||||
/// eigene Dateien lassen sich darüber nicht zum Server bringen. Navidrome
|
||||
/// bleibt die Streaming-Bibliothek, die Melo-Cloud ist der Sync-Speicher.
|
||||
class MeloCloudService {
|
||||
MeloCloudService({required this.auth, http.Client? client})
|
||||
: _client = client ?? http.Client();
|
||||
|
||||
static const basisUrl = 'https://cloud.baka-net.de/api/v1/cloud';
|
||||
|
||||
/// Der Server nimmt höchstens 50 MB je Datei an.
|
||||
static const maxUploadBytes = 50 * 1024 * 1024;
|
||||
|
||||
final BakaAuth auth;
|
||||
final http.Client _client;
|
||||
|
||||
bool get istAngemeldet => auth.istAngemeldet;
|
||||
|
||||
/// Liest die Titelliste aus einer Server-Antwort — inklusive Grabsteinen.
|
||||
@visibleForTesting
|
||||
static List<CloudSong> parseListe(String body) {
|
||||
final daten = jsonDecode(body) as Map<String, dynamic>;
|
||||
final fehler = daten['error'] as String?;
|
||||
if (fehler != null) throw CloudException(fehler);
|
||||
final liste = daten['songs'] as List? ?? const [];
|
||||
return [
|
||||
for (final j in liste) CloudSong.fromJson(j as Map<String, dynamic>),
|
||||
];
|
||||
}
|
||||
|
||||
/// Liest die Song-ID aus der Antwort auf einen Upload.
|
||||
@visibleForTesting
|
||||
static String? parseUpload(String body) {
|
||||
final daten = jsonDecode(body) as Map<String, dynamic>;
|
||||
final fehler = daten['error'] as String?;
|
||||
if (fehler != null) throw CloudException(fehler);
|
||||
return daten['song_id'] as String?;
|
||||
}
|
||||
|
||||
/// Liest die Favoriten-IDs aus einer Server-Antwort.
|
||||
@visibleForTesting
|
||||
static List<String> parseFavoriten(String body) {
|
||||
final daten = jsonDecode(body) as Map<String, dynamic>;
|
||||
final liste = daten['favorites'] as List? ?? const [];
|
||||
return [
|
||||
for (final j in liste) (j as Map<String, dynamic>)['id'] as String,
|
||||
];
|
||||
}
|
||||
|
||||
Map<String, String> get _kopf => {
|
||||
...auth.authHeader,
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
void _pruefeAnmeldung() {
|
||||
if (!auth.istAngemeldet) {
|
||||
throw CloudException('Bitte zuerst beim Baka-Konto anmelden');
|
||||
}
|
||||
}
|
||||
|
||||
/// Ob der Server antwortet. Für die Statusanzeige in den Einstellungen.
|
||||
Future<bool> erreichbar() async {
|
||||
try {
|
||||
final antwort = await _client
|
||||
.get(Uri.parse('$basisUrl/health'))
|
||||
.timeout(const Duration(seconds: 10));
|
||||
return antwort.statusCode == 200;
|
||||
} catch (e) {
|
||||
debugPrint('Melo-Cloud nicht erreichbar: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Alle Titel des angemeldeten Kontos, inklusive Grabsteinen.
|
||||
Future<List<CloudSong>> liste() async {
|
||||
_pruefeAnmeldung();
|
||||
final antwort = await _client
|
||||
.get(Uri.parse('$basisUrl/list'), headers: _kopf)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
_pruefeStatus(antwort);
|
||||
return parseListe(antwort.body);
|
||||
}
|
||||
|
||||
/// Lädt [datei] hoch und gibt die Server-ID zurück.
|
||||
///
|
||||
/// Der Server erkennt Dubletten selbst über die Prüfsumme und verknüpft
|
||||
/// sie mit dem bestehenden Titel — dieselbe Datei zweimal hochzuladen
|
||||
/// erzeugt also keine zweite Kopie.
|
||||
Future<String?> hochladen(File datei, {String? dateiname}) async {
|
||||
_pruefeAnmeldung();
|
||||
final groesse = await datei.length();
|
||||
if (groesse > maxUploadBytes) {
|
||||
throw CloudException(
|
||||
'Datei zu groß (${(groesse / 1024 / 1024).round()} MB, max 50 MB)');
|
||||
}
|
||||
final anfrage = http.MultipartRequest('POST', Uri.parse('$basisUrl/upload'))
|
||||
..headers.addAll(_kopf)
|
||||
..files.add(await http.MultipartFile.fromPath(
|
||||
'file',
|
||||
datei.path,
|
||||
filename: dateiname ?? datei.uri.pathSegments.last,
|
||||
));
|
||||
final gestreamt =
|
||||
await _client.send(anfrage).timeout(const Duration(seconds: 120));
|
||||
final antwort = await http.Response.fromStream(gestreamt);
|
||||
_pruefeStatus(antwort);
|
||||
return parseUpload(antwort.body);
|
||||
}
|
||||
|
||||
/// Holt den Titel [cloudId] und schreibt ihn nach [ziel].
|
||||
///
|
||||
/// Geschrieben wird erst in eine `.part`-Datei; nur der vollständige
|
||||
/// Download wird umbenannt. Ein Abbruch hinterlässt damit keine halbe
|
||||
/// Datei, die der Bibliotheks-Scan für Musik hielte.
|
||||
Future<bool> herunterladen(String cloudId, File ziel) async {
|
||||
_pruefeAnmeldung();
|
||||
final teil = File('${ziel.path}.part');
|
||||
try {
|
||||
final anfrage = http.Request('GET', Uri.parse('$basisUrl/download/$cloudId'))
|
||||
..headers.addAll(_kopf);
|
||||
final antwort =
|
||||
await _client.send(anfrage).timeout(const Duration(seconds: 180));
|
||||
if (antwort.statusCode != 200) {
|
||||
await logger.error('Cloud-Download $cloudId: HTTP ${antwort.statusCode}');
|
||||
return false;
|
||||
}
|
||||
await ziel.parent.create(recursive: true);
|
||||
await antwort.stream.pipe(teil.openWrite());
|
||||
await teil.rename(ziel.path);
|
||||
return true;
|
||||
} catch (e) {
|
||||
await logger.error('Cloud-Download $cloudId fehlgeschlagen: $e', e,
|
||||
StackTrace.current);
|
||||
if (await teil.exists()) await teil.delete();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Meldet die Löschung eines Titels — der Server setzt einen Grabstein,
|
||||
/// damit auch die anderen Geräte ihn entfernen.
|
||||
Future<void> loeschen(String cloudId) async {
|
||||
_pruefeAnmeldung();
|
||||
final antwort = await _client
|
||||
.post(
|
||||
Uri.parse('$basisUrl/delete'),
|
||||
headers: {..._kopf, 'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'song_id': cloudId}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
_pruefeStatus(antwort);
|
||||
}
|
||||
|
||||
Future<List<String>> favoriten() async {
|
||||
_pruefeAnmeldung();
|
||||
final antwort = await _client
|
||||
.get(Uri.parse('$basisUrl/favorites'), headers: _kopf)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
_pruefeStatus(antwort);
|
||||
return parseFavoriten(antwort.body);
|
||||
}
|
||||
|
||||
/// Ersetzt die Favoriten am Server durch [cloudIds].
|
||||
Future<void> setzeFavoriten(List<String> cloudIds) async {
|
||||
_pruefeAnmeldung();
|
||||
final antwort = await _client
|
||||
.post(
|
||||
Uri.parse('$basisUrl/favorites'),
|
||||
headers: {..._kopf, 'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'song_ids': cloudIds}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
_pruefeStatus(antwort);
|
||||
}
|
||||
|
||||
/// Meldet Wiedergaben. Der Server nimmt höchstens 100 je Aufruf an und
|
||||
/// verwirft Doppelmeldungen desselben Titels innerhalb einer Stunde.
|
||||
Future<void> meldeVerlauf(List<CloudVerlauf> eintraege) async {
|
||||
if (eintraege.isEmpty) return;
|
||||
_pruefeAnmeldung();
|
||||
final antwort = await _client
|
||||
.post(
|
||||
Uri.parse('$basisUrl/history'),
|
||||
headers: {..._kopf, 'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'entries': [for (final e in eintraege.take(100)) e.toJson()],
|
||||
}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
_pruefeStatus(antwort);
|
||||
}
|
||||
|
||||
void _pruefeStatus(http.Response antwort) {
|
||||
if (antwort.statusCode == 401) {
|
||||
throw CloudException('Anmeldung abgelaufen — bitte neu anmelden');
|
||||
}
|
||||
if (antwort.statusCode != 200) {
|
||||
throw CloudException('Server-Fehler (${antwort.statusCode})');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Ein Metadaten-Vorschlag aus der MusicBrainz-Datenbank.
|
||||
class MbVorschlag {
|
||||
const MbVorschlag({
|
||||
required this.titel,
|
||||
required this.kuenstler,
|
||||
required this.album,
|
||||
required this.releaseMbid,
|
||||
required this.score,
|
||||
});
|
||||
|
||||
final String titel;
|
||||
final String kuenstler;
|
||||
|
||||
/// Titel der ersten Veröffentlichung — leer, wenn MusicBrainz keine kennt.
|
||||
final String album;
|
||||
|
||||
/// Kennung der Veröffentlichung, mit der sich das Cover holen lässt.
|
||||
final String releaseMbid;
|
||||
|
||||
/// Wie gut der Treffer zur Anfrage passt (0–100).
|
||||
final int score;
|
||||
}
|
||||
|
||||
/// MusicBrainz verlangt eine erkennbare Kennung; anonyme Anfragen werden
|
||||
/// gesperrt.
|
||||
const _userAgent = 'Melo/1.0 (https://baka-net.de)';
|
||||
|
||||
/// Schlägt Titel, Künstler und Album eines Stücks online bei MusicBrainz nach.
|
||||
class MusicBrainzService {
|
||||
MusicBrainzService({http.Client? client}) : _client = client ?? http.Client();
|
||||
|
||||
final http.Client _client;
|
||||
|
||||
/// Liest die Vorschläge aus einer MusicBrainz-Antwort. Was sich nicht lesen
|
||||
/// lässt, ergibt eine leere Liste — die Suche ist nur eine Hilfe.
|
||||
static List<MbVorschlag> parseAntwort(String body) {
|
||||
final Object? daten;
|
||||
try {
|
||||
daten = jsonDecode(body);
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
if (daten is! Map<String, dynamic>) return const [];
|
||||
|
||||
final aufnahmen = daten['recordings'];
|
||||
if (aufnahmen is! List) return const [];
|
||||
|
||||
final vorschlaege = <MbVorschlag>[];
|
||||
for (final eintrag in aufnahmen) {
|
||||
if (eintrag is! Map<String, dynamic>) continue;
|
||||
final credits = eintrag['artist-credit'];
|
||||
final erstesCredit = credits is List && credits.isNotEmpty
|
||||
? credits.first as Map<String, dynamic>?
|
||||
: null;
|
||||
final releases = eintrag['releases'];
|
||||
final erstesRelease = releases is List && releases.isNotEmpty
|
||||
? releases.first as Map<String, dynamic>?
|
||||
: null;
|
||||
|
||||
vorschlaege.add(MbVorschlag(
|
||||
titel: eintrag['title'] as String? ?? '',
|
||||
kuenstler: erstesCredit?['name'] as String? ?? '',
|
||||
album: erstesRelease?['title'] as String? ?? '',
|
||||
releaseMbid: erstesRelease?['id'] as String? ?? '',
|
||||
score: eintrag['score'] as int? ?? 0,
|
||||
));
|
||||
}
|
||||
return vorschlaege;
|
||||
}
|
||||
|
||||
/// Adresse des Frontcovers einer Veröffentlichung im Cover Art Archive.
|
||||
String coverUrl(String releaseMbid) =>
|
||||
'https://coverartarchive.org/release/$releaseMbid/front-250';
|
||||
|
||||
/// Sucht zu [titel] (und wenn bekannt [kuenstler]) passende Aufnahmen.
|
||||
Future<List<MbVorschlag>> suche({
|
||||
required String titel,
|
||||
String? kuenstler,
|
||||
}) async {
|
||||
final teile = ['recording:"${_maskiere(titel)}"'];
|
||||
final name = kuenstler?.trim() ?? '';
|
||||
if (name.isNotEmpty) teile.add('artist:"${_maskiere(name)}"');
|
||||
|
||||
final ziel = Uri.https('musicbrainz.org', '/ws/2/recording', {
|
||||
'query': teile.join(' AND '),
|
||||
'fmt': 'json',
|
||||
'limit': '5',
|
||||
});
|
||||
|
||||
final antwort = await _client
|
||||
.get(ziel, headers: const {'User-Agent': _userAgent})
|
||||
.timeout(const Duration(seconds: 15));
|
||||
if (antwort.statusCode != 200) {
|
||||
debugPrint('MusicBrainz antwortet mit ${antwort.statusCode}');
|
||||
throw Exception('MusicBrainz antwortet mit ${antwort.statusCode}');
|
||||
}
|
||||
return parseAntwort(utf8.decode(antwort.bodyBytes));
|
||||
}
|
||||
|
||||
/// Anführungszeichen und Backslashes würden die Lucene-Abfrage zerlegen.
|
||||
static String _maskiere(String text) =>
|
||||
text.replaceAll('\\', r'\\').replaceAll('"', r'\"');
|
||||
}
|
||||
@@ -351,36 +351,56 @@ class NavidromeService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uri?> streamAndCacheToLocal(String songId, CacheManager cache) async {
|
||||
/// Nur eine vollständige Audio-Antwort darf in den Cache. Subsonic meldet
|
||||
/// Fehler mit HTTP 200 und einem JSON-Rumpf — landet der als ".mp3" im
|
||||
/// Cache, ist der Titel dauerhaft unabspielbar, weil jeder weitere Versuch
|
||||
/// den Cache-Treffer nimmt.
|
||||
@visibleForTesting
|
||||
static bool istAudioAntwort(int statusCode, String? contentType) {
|
||||
if (statusCode != 200) return false;
|
||||
final typ = contentType?.toLowerCase() ?? '';
|
||||
return typ.startsWith('audio/') || typ.startsWith('application/octet-stream');
|
||||
}
|
||||
|
||||
/// Schlüssel, unter dem ein Server-Titel im Cache liegt.
|
||||
///
|
||||
/// Bewusst die Song-ID und nicht die Stream-Adresse: die trägt Token und
|
||||
/// Salt, und beides wird bei jedem App-Start neu gewürfelt. Als Schlüssel
|
||||
/// hätte damit derselbe Titel jedes Mal einen anderen — der Cache wäre nie
|
||||
/// wieder auffindbar und würde nur wachsen.
|
||||
static String cacheSchluessel(String songId) => 'navidrome:$songId';
|
||||
|
||||
/// Legt den Titel [songId] vollständig im Cache ab, damit er später ohne
|
||||
/// Netz läuft. Läuft im Hintergrund — die Wiedergabe wartet nie darauf.
|
||||
///
|
||||
/// Geschrieben wird zuerst in eine `.part`-Datei; erst der vollständige,
|
||||
/// als Audio bestätigte Download wird umbenannt. Ein Abbruch hinterlässt
|
||||
/// damit keine halbe Datei, die als gültiger Cache-Treffer gälte.
|
||||
Future<File?> ladeInCache(String songId, Uri streamUri, CacheManager cache) async {
|
||||
final ziel = await cache.getCacheFile(cacheSchluessel(songId));
|
||||
if (await ziel.exists()) return ziel;
|
||||
|
||||
final teil = File('${ziel.path}.part');
|
||||
final klient = http.Client();
|
||||
try {
|
||||
final streamUri = streamUrl(songId);
|
||||
final cacheFile = await cache.getCacheFile(streamUri.toString());
|
||||
|
||||
if (await cacheFile.exists()) {
|
||||
debugPrint('Cache-Hit: ${cacheFile.path}');
|
||||
return Uri.file(cacheFile.path);
|
||||
final antwort = await klient
|
||||
.send(http.Request('GET', streamUri))
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (!istAudioAntwort(antwort.statusCode, antwort.headers['content-type'])) {
|
||||
await logger.error('Cache abgebrochen: HTTP ${antwort.statusCode}, '
|
||||
'Typ ${antwort.headers['content-type'] ?? '-'}');
|
||||
return null;
|
||||
}
|
||||
|
||||
debugPrint('Cache-Miss: Starten download zu ${cacheFile.path}');
|
||||
final request = http.Request('GET', streamUri);
|
||||
final response = await http.Client().send(request).timeout(const Duration(seconds: 30));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
await logger.error('Stream-Fehler: HTTP ${response.statusCode}');
|
||||
return streamUri;
|
||||
}
|
||||
|
||||
final sink = cacheFile.openWrite();
|
||||
await for (final chunk in response.stream) {
|
||||
sink.add(chunk);
|
||||
}
|
||||
await sink.close();
|
||||
|
||||
debugPrint('Cache-Speicherung erfolgreich: ${cacheFile.path}');
|
||||
return Uri.file(cacheFile.path);
|
||||
await antwort.stream.pipe(teil.openWrite());
|
||||
await teil.rename(ziel.path);
|
||||
debugPrint('Im Cache abgelegt: ${ziel.path}');
|
||||
return ziel;
|
||||
} catch (e) {
|
||||
await logger.error('streamAndCacheToLocal Fehler: $e', e, StackTrace.current);
|
||||
return streamUrl(songId);
|
||||
await logger.error('Cache-Download fehlgeschlagen: $e', e, StackTrace.current);
|
||||
if (await teil.exists()) await teil.delete();
|
||||
return null;
|
||||
} finally {
|
||||
klient.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../library/database.dart';
|
||||
import 'media_store.dart';
|
||||
import 'melo_cloud_service.dart';
|
||||
|
||||
/// Was beim Abgleich mit welchen Titeln zu tun ist.
|
||||
///
|
||||
/// Ein Titel gilt über [Song.cloudId] als „am Server bekannt". Alles andere
|
||||
/// folgt daraus: kennt der Server ihn nicht, geht er hoch; kennt das Gerät
|
||||
/// einen Server-Titel nicht, kommt er herunter; hat der Server einen Grabstein
|
||||
/// gesetzt, verschwindet er auch hier.
|
||||
class SyncPlan {
|
||||
const SyncPlan({
|
||||
required this.herunterladen,
|
||||
required this.hochladen,
|
||||
required this.lokalLoeschen,
|
||||
required this.serverLoeschen,
|
||||
});
|
||||
|
||||
/// Titel, die es nur am Server gibt.
|
||||
final List<CloudSong> herunterladen;
|
||||
|
||||
/// Titel, die es nur auf dem Gerät gibt.
|
||||
final List<Song> hochladen;
|
||||
|
||||
/// Titel, die der Server als gelöscht meldet.
|
||||
final List<Song> lokalLoeschen;
|
||||
|
||||
/// Titel, die hier gelöscht wurden und deren Grabstein der Server noch
|
||||
/// nicht kennt — sonst tauchen sie auf den anderen Geräten weiter auf.
|
||||
final List<Song> serverLoeschen;
|
||||
|
||||
bool get istLeer =>
|
||||
herunterladen.isEmpty &&
|
||||
hochladen.isEmpty &&
|
||||
lokalLoeschen.isEmpty &&
|
||||
serverLoeschen.isEmpty;
|
||||
|
||||
int get gesamt =>
|
||||
herunterladen.length +
|
||||
hochladen.length +
|
||||
lokalLoeschen.length +
|
||||
serverLoeschen.length;
|
||||
}
|
||||
|
||||
/// Stellt Gerät und Server gegenüber. Reine Funktion — der eigentliche
|
||||
/// Abgleich in [SyncService] führt nur noch aus, was hier entschieden wurde.
|
||||
SyncPlan planeSync({
|
||||
required List<Song> lokal,
|
||||
required List<CloudSong> server,
|
||||
}) {
|
||||
final serverNachId = {for (final s in server) s.id: s};
|
||||
final bekannteCloudIds = <String>{};
|
||||
|
||||
final hochladen = <Song>[];
|
||||
final lokalLoeschen = <Song>[];
|
||||
final serverLoeschen = <Song>[];
|
||||
|
||||
for (final song in lokal) {
|
||||
final cloudId = song.cloudId;
|
||||
if (cloudId != null) bekannteCloudIds.add(cloudId);
|
||||
|
||||
if (song.deleted) {
|
||||
// Hier gelöscht: der Server muss den Grabstein bekommen, sonst laden
|
||||
// ihn die anderen Geräte weiter herunter.
|
||||
final amServer = cloudId == null ? null : serverNachId[cloudId];
|
||||
if (amServer != null && !amServer.geloescht) serverLoeschen.add(song);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cloudId == null) {
|
||||
hochladen.add(song);
|
||||
continue;
|
||||
}
|
||||
final amServer = serverNachId[cloudId];
|
||||
if (amServer == null) {
|
||||
// Der Server kennt die Verknüpfung nicht mehr (z. B. Konto gewechselt).
|
||||
// Erneut hochladen ist sicherer als den Titel stillschweigend zu verlieren.
|
||||
hochladen.add(song);
|
||||
} else if (amServer.geloescht) {
|
||||
lokalLoeschen.add(song);
|
||||
}
|
||||
}
|
||||
|
||||
final herunterladen = [
|
||||
for (final s in server)
|
||||
if (!s.geloescht && !bekannteCloudIds.contains(s.id)) s,
|
||||
];
|
||||
|
||||
return SyncPlan(
|
||||
herunterladen: herunterladen,
|
||||
hochladen: hochladen,
|
||||
lokalLoeschen: lokalLoeschen,
|
||||
serverLoeschen: serverLoeschen,
|
||||
);
|
||||
}
|
||||
|
||||
/// Wie oft höchstens automatisch abgeglichen wird. Ein Abgleich beim
|
||||
/// Zurückkehren in die App darf nicht bei jedem Tab-Wechsel losrennen.
|
||||
const autoSyncAbstand = Duration(minutes: 15);
|
||||
|
||||
/// Ob jetzt automatisch abgeglichen werden soll.
|
||||
bool sollAutoSync(DateTime? letzterLauf, DateTime jetzt) =>
|
||||
letzterLauf == null || jetzt.difference(letzterLauf) >= autoSyncAbstand;
|
||||
|
||||
/// Hält Gerät und Server auf demselben Stand: lädt neue Server-Titel herunter,
|
||||
/// bringt eigene Dateien hoch, zieht Löschungen nach und meldet Favoriten und
|
||||
/// Wiedergaben. Alle Geräte am selben Konto sehen dadurch dasselbe.
|
||||
class SyncService extends ChangeNotifier {
|
||||
SyncService({
|
||||
required this.db,
|
||||
required this.cloud,
|
||||
this.mediaStore = const MediaStore(),
|
||||
Future<Directory> Function()? musikOrdner,
|
||||
}) : _musikOrdner = musikOrdner ?? _standardMusikOrdner;
|
||||
|
||||
static const _letzterLaufKey = 'cloud_sync_letzter_lauf';
|
||||
static const _verlaufStandKey = 'cloud_sync_verlauf_stand';
|
||||
static const _uuid = Uuid();
|
||||
|
||||
final MeloDb db;
|
||||
final MeloCloudService cloud;
|
||||
final MediaStore mediaStore;
|
||||
final Future<Directory> Function() _musikOrdner;
|
||||
|
||||
bool _laeuft = false;
|
||||
int _erledigt = 0;
|
||||
int _gesamt = 0;
|
||||
String? _fehler;
|
||||
String? _status;
|
||||
DateTime? _letzterLauf;
|
||||
|
||||
bool get laeuft => _laeuft;
|
||||
int get erledigt => _erledigt;
|
||||
int get gesamt => _gesamt;
|
||||
String? get fehler => _fehler;
|
||||
String? get status => _status;
|
||||
DateTime? get letzterLauf => _letzterLauf;
|
||||
|
||||
Future<void> laden() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final ms = prefs.getInt(_letzterLaufKey);
|
||||
if (ms != null) _letzterLauf = DateTime.fromMillisecondsSinceEpoch(ms);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Gleicht ab, wenn seit dem letzten Lauf genug Zeit vergangen ist.
|
||||
/// Für den App-Start und die Rückkehr in die App.
|
||||
Future<void> automatisch() async {
|
||||
if (_laeuft || !cloud.istAngemeldet) return;
|
||||
if (!sollAutoSync(_letzterLauf, DateTime.now())) return;
|
||||
await synchronisiere();
|
||||
}
|
||||
|
||||
/// Vollständiger Abgleich. Ein Fehler in einem Schritt bricht den ganzen
|
||||
/// Lauf nicht ab — was geht, wird erledigt, der Rest beim nächsten Mal.
|
||||
Future<void> synchronisiere() async {
|
||||
if (_laeuft) return;
|
||||
if (!cloud.istAngemeldet) {
|
||||
_fehler = 'Bitte zuerst beim Baka-Konto anmelden';
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
_laeuft = true;
|
||||
_fehler = null;
|
||||
_erledigt = 0;
|
||||
_gesamt = 0;
|
||||
_melde('Vergleiche mit dem Server …');
|
||||
|
||||
try {
|
||||
final plan = planeSync(
|
||||
lokal: await db.allSongs(),
|
||||
server: await cloud.liste(),
|
||||
);
|
||||
_gesamt = plan.gesamt;
|
||||
notifyListeners();
|
||||
|
||||
await _ziehLoeschungenNach(plan.lokalLoeschen);
|
||||
await _meldeLoeschungen(plan.serverLoeschen);
|
||||
await _ladeHerunter(plan.herunterladen);
|
||||
await _ladeHoch(plan.hochladen);
|
||||
await _gleicheFavoritenAb();
|
||||
await _meldeVerlauf();
|
||||
|
||||
_letzterLauf = DateTime.now();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_letzterLaufKey, _letzterLauf!.millisecondsSinceEpoch);
|
||||
} on CloudException catch (e) {
|
||||
_fehler = e.message;
|
||||
} catch (e) {
|
||||
debugPrint('Sync fehlgeschlagen: $e');
|
||||
_fehler = 'Abgleich fehlgeschlagen: $e';
|
||||
} finally {
|
||||
_laeuft = false;
|
||||
_status = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ziehLoeschungenNach(List<Song> songs) async {
|
||||
if (songs.isEmpty) return;
|
||||
_melde('Entferne ${songs.length} am Server gelöschte Titel …');
|
||||
await db.tombstoneByCloudIds([for (final s in songs) s.cloudId!]);
|
||||
_erledigt += songs.length;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _meldeLoeschungen(List<Song> songs) async {
|
||||
for (final song in songs) {
|
||||
_melde('Melde Löschung von „${song.title}“ …');
|
||||
try {
|
||||
await cloud.loeschen(song.cloudId!);
|
||||
} on CloudException catch (e) {
|
||||
// Eine abgelehnte Löschung darf den Lauf nicht beenden.
|
||||
debugPrint('Löschung „${song.title}“ übersprungen: ${e.message}');
|
||||
}
|
||||
_erledigt++;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ladeHerunter(List<CloudSong> songs) async {
|
||||
if (songs.isEmpty) return;
|
||||
final ordner = await _musikOrdner();
|
||||
for (final cloudSong in songs) {
|
||||
_melde('Lade „${cloudSong.titel}“ …');
|
||||
final datei = File(p.join(
|
||||
ordner.path,
|
||||
'${_sichererDateiname(cloudSong.titel)}-${cloudSong.id}.mp3',
|
||||
));
|
||||
if (!await cloud.herunterladen(cloudSong.id, datei)) {
|
||||
_erledigt++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// In den öffentlichen Musikordner eintragen: sonst kennt der
|
||||
// MediaStore die Datei nicht und der nächste Scan tombstoned sie.
|
||||
final pfad = await mediaStore.veroeffentliche(
|
||||
quellPfad: datei.path,
|
||||
titel: cloudSong.titel,
|
||||
kuenstler: cloudSong.kuenstler,
|
||||
) ??
|
||||
datei.path;
|
||||
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
await db.upsertSongs([
|
||||
SongsCompanion.insert(
|
||||
id: _uuid.v4(),
|
||||
path: pfad,
|
||||
title: cloudSong.titel,
|
||||
artist: Value(cloudSong.kuenstler.isEmpty ? null : cloudSong.kuenstler),
|
||||
durationMs: Value(cloudSong.dauerSekunden > 0
|
||||
? cloudSong.dauerSekunden * 1000
|
||||
: null),
|
||||
dateAddedMs: now,
|
||||
updatedAtMs: now,
|
||||
cloudId: Value(cloudSong.id),
|
||||
),
|
||||
]);
|
||||
_erledigt++;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ladeHoch(List<Song> songs) async {
|
||||
for (final song in songs) {
|
||||
final datei = File(song.path);
|
||||
if (!await datei.exists()) {
|
||||
_erledigt++;
|
||||
continue;
|
||||
}
|
||||
_melde('Sende „${song.title}“ …');
|
||||
try {
|
||||
final cloudId = await cloud.hochladen(
|
||||
datei,
|
||||
dateiname: '${_sichererDateiname(song.title)}${p.extension(song.path)}',
|
||||
);
|
||||
if (cloudId != null) await db.setCloudId(song.id, cloudId);
|
||||
} on CloudException catch (e) {
|
||||
// Eine zu große oder abgelehnte Datei darf den Lauf nicht beenden.
|
||||
debugPrint('Upload „${song.title}“ übersprungen: ${e.message}');
|
||||
}
|
||||
_erledigt++;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _gleicheFavoritenAb() async {
|
||||
_melde('Gleiche Favoriten ab …');
|
||||
final lokal = await db.allSongs();
|
||||
final cloudIdVon = {
|
||||
for (final s in lokal)
|
||||
if (s.cloudId != null) s.id: s.cloudId!,
|
||||
};
|
||||
final favoritenIds = await db.favoriteSongIds();
|
||||
final cloudFavoriten = [
|
||||
for (final id in favoritenIds)
|
||||
if (cloudIdVon[id] != null) cloudIdVon[id]!,
|
||||
];
|
||||
await cloud.setzeFavoriten(cloudFavoriten);
|
||||
}
|
||||
|
||||
Future<void> _meldeVerlauf() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final stand = prefs.getInt(_verlaufStandKey) ?? 0;
|
||||
final eintraege = await db.historySince(stand);
|
||||
if (eintraege.isEmpty) return;
|
||||
|
||||
_melde('Melde ${eintraege.length} Wiedergaben …');
|
||||
final cloudIdVon = {
|
||||
for (final s in await db.allSongs())
|
||||
if (s.cloudId != null) s.id: s.cloudId!,
|
||||
};
|
||||
final zuMelden = [
|
||||
for (final e in eintraege)
|
||||
if (cloudIdVon[e.songId] != null)
|
||||
CloudVerlauf(
|
||||
cloudId: cloudIdVon[e.songId]!,
|
||||
gespieltAm: DateTime.fromMillisecondsSinceEpoch(e.playedAtMs),
|
||||
positionSekunden: e.positionMs ~/ 1000,
|
||||
),
|
||||
];
|
||||
await cloud.meldeVerlauf(zuMelden);
|
||||
await prefs.setInt(_verlaufStandKey, eintraege.first.playedAtMs);
|
||||
}
|
||||
|
||||
void _melde(String text) {
|
||||
_status = text;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
String _sichererDateiname(String titel) =>
|
||||
titel.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim();
|
||||
|
||||
Future<Directory> _standardMusikOrdner() async {
|
||||
// Zwischenablage für den Download; danach wandert die Datei über den
|
||||
// MediaStore in den öffentlichen Musikordner.
|
||||
final dir = Directory(p.join(Directory.systemTemp.path, 'melo_cloud_dl'));
|
||||
await dir.create(recursive: true);
|
||||
return dir;
|
||||
}
|
||||
@@ -59,9 +59,11 @@ class YtDownloadService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Lädt [url] herunter und legt die MP3 in [zielOrdner] ab.
|
||||
/// Mit [cookies] nutzt der Proxy seine YouTube-Anmeldung — nötig für
|
||||
/// altersbeschränkte Videos.
|
||||
/// Gibt bei Erfolg das Ergebnis zurück, sonst `null` (siehe [fehler]).
|
||||
Future<YtErgebnis?> herunterladen(String url,
|
||||
{required String zielOrdner}) async {
|
||||
{required String zielOrdner, bool cookies = true}) async {
|
||||
_fehler = null;
|
||||
_laeuft = true;
|
||||
notifyListeners();
|
||||
@@ -83,7 +85,7 @@ class YtDownloadService extends ChangeNotifier {
|
||||
.post(
|
||||
Uri.parse('$proxyUrl/api/yt-dl'),
|
||||
headers: {...auth.authHeader, 'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'url': url}),
|
||||
body: jsonEncode({'url': url, 'cookies': cookies}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 180));
|
||||
} catch (e) {
|
||||
|
||||
@@ -5,16 +5,20 @@ import '../library/database.dart';
|
||||
import '../library/library_service.dart';
|
||||
import '../library/permissions.dart';
|
||||
import '../library/playlist_service.dart';
|
||||
import '../services/baka_auth.dart';
|
||||
import '../services/cache_manager.dart';
|
||||
import '../services/logger_service.dart';
|
||||
import '../services/navidrome_service.dart';
|
||||
import '../services/offline_mode.dart';
|
||||
import '../services/sync_service.dart';
|
||||
import '../shared/theme.dart';
|
||||
import '../player/audio_effects.dart';
|
||||
import 'app_settings.dart';
|
||||
import 'equalizer_screen.dart';
|
||||
import 'library_stats.dart';
|
||||
|
||||
/// Settings-Tab: Bibliotheks-Statistik, Berechtigungen, Navidrome, Über Melo.
|
||||
/// Settings-Tab: Bibliotheks-Statistik, Geräte-Abgleich, Berechtigungen,
|
||||
/// Navidrome, Über Melo.
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@@ -227,6 +231,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
const _SectionLabel('Geräte-Abgleich'),
|
||||
const _CloudSyncBereich(),
|
||||
const Divider(height: 1),
|
||||
const _SectionLabel('Berechtigungen'),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.mic_none),
|
||||
@@ -490,3 +497,69 @@ class _SectionLabel extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Abgleich mit der Melo-Cloud: was auf einem Gerät dazukommt, verschwindet
|
||||
/// oder gehört wird, steht danach auf allen Geräten gleich.
|
||||
class _CloudSyncBereich extends StatelessWidget {
|
||||
const _CloudSyncBereich();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = context.watch<BakaAuth>();
|
||||
final sync = context.watch<SyncService>();
|
||||
|
||||
if (!auth.istAngemeldet) {
|
||||
return const ListTile(
|
||||
leading: Icon(Icons.cloud_off),
|
||||
title: Text('Nicht angemeldet'),
|
||||
subtitle: Text(
|
||||
'Für den Abgleich im Tab „Online“ beim Baka-Konto anmelden'),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.cloud_sync),
|
||||
title: Text('Angemeldet als ${auth.benutzer}'),
|
||||
subtitle: Text(sync.status ??
|
||||
(sync.letzterLauf != null
|
||||
? 'Zuletzt abgeglichen: ${_zeitText(sync.letzterLauf!)}'
|
||||
: 'Noch nie abgeglichen')),
|
||||
trailing: sync.laeuft
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: IconButton(
|
||||
tooltip: 'Jetzt abgleichen',
|
||||
icon: const Icon(Icons.sync),
|
||||
onPressed: sync.synchronisiere,
|
||||
),
|
||||
),
|
||||
if (sync.laeuft && sync.gesamt > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: LinearProgressIndicator(
|
||||
value: sync.erledigt / sync.gesamt,
|
||||
color: MeloTheme.red,
|
||||
),
|
||||
),
|
||||
if (sync.fehler != null)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.error_outline, color: Colors.orange),
|
||||
title: Text(sync.fehler!,
|
||||
style: const TextStyle(color: Colors.orange, fontSize: 13)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _zeitText(DateTime zeit) {
|
||||
final vergangen = DateTime.now().difference(zeit);
|
||||
if (vergangen.inMinutes < 1) return 'gerade eben';
|
||||
if (vergangen.inHours < 1) return 'vor ${vergangen.inMinutes} Min.';
|
||||
if (vergangen.inDays < 1) return 'vor ${vergangen.inHours} Std.';
|
||||
return 'vor ${vergangen.inDays} Tagen';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user