Cover-Farbe, Teilen aus YouTube, Android Auto, geschärfte Typografie
P3-Liste aus dem UX-Review: Player nimmt Cover-Farbe auf, "Teilen" aus YouTube trägt die Adresse in den Downloader ein, Android Auto blättert durch die Bibliothek, Laufweite der Typografie geschärft, Überschriften vereinheitlicht. 533 Tests grün, flutter analyze ohne Befund.
This commit is contained in:
@@ -40,7 +40,12 @@ erscheint auf Android 13+ keine Wiedergabe-Benachrichtigung.
|
||||
> (Hero-Übergang, Wischgesten, Künstlerseite mit Alben, Reduced Motion
|
||||
> app-weit, Querformat/Tablet, Alphabet-Sprung, Mehrfachauswahl).
|
||||
>
|
||||
> Offen sind damit noch die P3-Liste unten sowie **K4** in seiner
|
||||
> **Nachtrag 2026-08-24 (zweiter):** Auch die **P3-Liste** ist abgearbeitet —
|
||||
> Cover-Farbe im Player, Teilen-Intent, Android Auto. Bei V2 (eigene Schrift)
|
||||
> hat Dustin entschieden, bei der System-Schrift zu bleiben und stattdessen
|
||||
> die Typografie zu schärfen; das ist umgesetzt.
|
||||
>
|
||||
> Offen bleibt damit **K4** in seiner
|
||||
> korrigierten Fassung (Album-Detailansicht **lokal**, mit Tracknummer und
|
||||
> Schema-Erweiterung — die Server-Seite davon ist mit G2 erledigt).
|
||||
|
||||
|
||||
@@ -5,6 +5,60 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 🎨 Cover-Farbe, Teilen, Android Auto, geschärfte Typografie (2026-08-24)
|
||||
|
||||
Die P3-Liste aus dem Review.
|
||||
|
||||
- 🌈 **Der Player nimmt die Farbe des Covers auf.** Bisher war er für jeden
|
||||
Titel gleich schwarz; jetzt läuft ein Verlauf aus der prägenden Farbe des
|
||||
Albums nach unten in Schwarz aus. **Ohne neue Abhängigkeit:** die
|
||||
Farbwahl ist eine reine Funktion über die Bildpunkte und dadurch prüfbar.
|
||||
Nicht einfach die häufigste Farbe — Cover sind oft überwiegend dunkel oder
|
||||
grau, und dann sähe wieder alles gleich aus. Häufigkeit **und** Farbigkeit
|
||||
entscheiden zusammen. Anschließend wird so weit abgedunkelt, dass weiße
|
||||
Schrift darauf lesbar bleibt.
|
||||
|
||||
- 📤 **„Teilen → Melo" aus der YouTube-App.** Vorher musste man die Adresse
|
||||
von Hand kopieren und einfügen. Die App springt in den Download-Bereich und
|
||||
trägt die Adresse ein — **ohne** den Download zu starten: Kategorie und
|
||||
Speicherort will man vorher noch wählen können. Die geteilte Nachricht
|
||||
enthält meist Titel und „über @YouTube" drumherum; daraus wird die Adresse
|
||||
herausgelöst. Bewusst nicht auf YouTube eingeschränkt — der Downloader
|
||||
läuft über yt-dlp und kann mehr, und was davon geht, entscheidet der Server.
|
||||
|
||||
- 🚗 **Android Auto blättert jetzt wirklich durch die Bibliothek.** Der
|
||||
MediaBrowserService war im Manifest zwar angemeldet, lieferte aber nie
|
||||
Inhalte — die Bibliothek blieb dort leer. Vier Ordner wie in der App:
|
||||
Favoriten · Zuletzt hinzugefügt · Kategorien · Künstler. Ein Titel im Auto
|
||||
startet den **ganzen Ordner** ab dieser Stelle, nicht nur das eine Lied.
|
||||
Die Adressen halten Doppelpunkte und Schrägstriche aus — „Intro: Anfang"
|
||||
und „AC/DC" sind normale Namen.
|
||||
|
||||
- 🔠 **Typografie geschärft, ohne neue Schrift.** Melo bleibt bei der
|
||||
System-Schrift: sie ist immer da, trägt jede Sprache und kostet nichts an
|
||||
App-Größe. Geschärft wurde vor allem die **Laufweite** — große Grade stehen
|
||||
enger, damit die Zeile als ein Wortbild liest; Abschnitts-Beschriftungen in
|
||||
Großbuchstaben dagegen weiter, weil die Luft brauchen.
|
||||
|
||||
- 🐛 **Dabei gefunden:** Die drei Bildschirm-Überschriften („Download",
|
||||
„Favoriten", „Suchen") setzten ihre Größe **von Hand**, während das Theme
|
||||
eine eigene Stufe dafür vorhielt — die nie benutzt wurde. Eine Änderung am
|
||||
Theme wäre nirgends angekommen. Jetzt gehen alle drei durch eine gemeinsame
|
||||
Überschrift.
|
||||
|
||||
- ↩️ **Und eine Rücknahme:** Ich hatte den Fließtext dichter gesetzt und damit
|
||||
eine Regel gebrochen, die seit Längerem als Test festgehalten ist
|
||||
(„Fließtext hat mindestens 1,35 Zeilenhöhe"). Die Regel ist älter als diese
|
||||
Politur und hat einen Grund — zurückgenommen wurde deshalb mein Code, nicht
|
||||
der Test.
|
||||
|
||||
- ✅ **Getestet:** 533 Tests grün (vorher 500), `flutter analyze` ohne Befund,
|
||||
Release-APK gebaut; Auto-Eintrag und Teilen-Filter im fertigen APK
|
||||
nachgeprüft. Neu: 9 Tests für die Cover-Farbe, 8 für das Herauslösen der
|
||||
geteilten Adresse, 9 für die Adressen des Auto-Baums und 7 für die
|
||||
Stufenleiter der Schrift.
|
||||
|
||||
|
||||
### ✨ Politur: Wischen, wandernde Cover, Alphabet-Sprung, Mehrfachauswahl (2026-08-24)
|
||||
|
||||
Die P2-Liste aus dem Review — lauter Kleinigkeiten, die man einzeln nicht
|
||||
|
||||
@@ -48,12 +48,25 @@
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<!-- "Teilen -> Melo" aus der YouTube-App. Vorher musste man die
|
||||
Adresse von Hand kopieren und einfuegen. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<!-- Android Auto: ohne diesen Eintrag taucht Melo dort nicht auf,
|
||||
auch wenn der MediaBrowserService unten angemeldet ist. Die
|
||||
Inhalte liefert MeloAudioHandler.getChildren. -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc"/>
|
||||
<service
|
||||
android:name="com.ryanheise.audioservice.AudioService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package de.baka.melo
|
||||
|
||||
import android.content.Intent
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/**
|
||||
* Reicht Text weiter, den eine andere App ueber "Teilen" an Melo schickt —
|
||||
* in der Regel eine YouTube-Adresse.
|
||||
*
|
||||
* Eigene Bruecke statt eines Zusatzpakets: das Projekt hat fuer MediaStore
|
||||
* und Klangeffekte bereits eigene (siehe MediaStoreBridge).
|
||||
*/
|
||||
class GeteiltBridge {
|
||||
companion object {
|
||||
const val CHANNEL = "de.baka.melo/geteilt"
|
||||
}
|
||||
|
||||
private var offen: String? = null
|
||||
|
||||
/**
|
||||
* Merkt sich den Text eines SEND-Intents. Wird sowohl beim Start als
|
||||
* auch bei onNewIntent aufgerufen: die Activity laeuft mit singleTop,
|
||||
* ein zweites Teilen startet sie also nicht neu.
|
||||
*/
|
||||
fun nimm(intent: Intent?) {
|
||||
if (intent?.action != Intent.ACTION_SEND) return
|
||||
if (intent.type != "text/plain") return
|
||||
offen = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
// Aus dem Intent entfernen, damit ein Lagewechsel (Drehen) ihn nicht
|
||||
// ein zweites Mal liefert.
|
||||
intent.removeExtra(Intent.EXTRA_TEXT)
|
||||
}
|
||||
|
||||
fun handle(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"hole" -> {
|
||||
// Nach dem Abholen leeren: sonst spraenge die App bei jedem
|
||||
// Wiedereintritt erneut in den Downloader.
|
||||
val text = offen
|
||||
offen = null
|
||||
result.success(text)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
package de.baka.melo
|
||||
|
||||
import android.content.Intent
|
||||
import com.ryanheise.audioservice.AudioServiceActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity : AudioServiceActivity() {
|
||||
private var audioEffects: AudioEffectsBridge? = null
|
||||
private val geteilt = GeteiltBridge()
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
@@ -21,6 +23,24 @@ class MainActivity : AudioServiceActivity() {
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
MediaStoreBridge.CHANNEL
|
||||
).setMethodCallHandler(mediaStore::handle)
|
||||
|
||||
// Den Intent, mit dem die App gestartet wurde, gleich mitnehmen —
|
||||
// Dart fragt kurz danach danach.
|
||||
geteilt.nimm(intent)
|
||||
MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
GeteiltBridge.CHANNEL
|
||||
).setMethodCallHandler(geteilt::handle)
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Activity laeuft mit singleTop: ein zweites Teilen startet sie
|
||||
* nicht neu, sondern liefert hier einen neuen Intent.
|
||||
*/
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
geteilt.nimm(intent)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Meldet Melo bei Android Auto als Musik-App an. Ohne diese Datei taucht die
|
||||
App dort gar nicht erst auf — der MediaBrowserService allein reicht nicht.
|
||||
-->
|
||||
<automotiveApp>
|
||||
<uses name="media"/>
|
||||
</automotiveApp>
|
||||
@@ -15,6 +15,7 @@ import '../services/navidrome_service.dart';
|
||||
import '../services/server_neuheiten.dart';
|
||||
import '../services/yt_download_service.dart';
|
||||
import '../shared/sub_tabs.dart';
|
||||
import '../shared/ueberschrift.dart';
|
||||
import 'download_einordnung.dart';
|
||||
import 'server_titel_screen.dart';
|
||||
import '../shared/theme.dart';
|
||||
@@ -24,7 +25,19 @@ import '../shared/theme.dart';
|
||||
/// Titel werden vom [MeloAudioHandler] automatisch lokal zwischengespeichert
|
||||
/// und stehen danach offline zur Verfügung.
|
||||
class DownloadsScreen extends StatefulWidget {
|
||||
const DownloadsScreen({super.key});
|
||||
const DownloadsScreen({
|
||||
super.key,
|
||||
this.adresse,
|
||||
this.onAdresseUebernommen,
|
||||
});
|
||||
|
||||
/// Adresse aus einem „Teilen"-Aufruf. Wird ins Eingabefeld übernommen und
|
||||
/// der YouTube-Unterreiter aufgeschlagen.
|
||||
final String? adresse;
|
||||
|
||||
/// Meldet zurück, dass die Adresse angekommen ist — sie soll beim nächsten
|
||||
/// Wechsel in diesen Bereich nicht erneut auftauchen.
|
||||
final VoidCallback? onAdresseUebernommen;
|
||||
|
||||
@override
|
||||
State<DownloadsScreen> createState() => _DownloadsScreenState();
|
||||
@@ -34,27 +47,35 @@ class _DownloadsScreenState extends State<DownloadsScreen> {
|
||||
/// 0 = YouTube, 1 = Server.
|
||||
int _subTab = 0;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(DownloadsScreen alt) {
|
||||
super.didUpdateWidget(alt);
|
||||
// Eine geteilte Adresse gehört zum YouTube-Bereich; wer gerade beim
|
||||
// Server war, soll trotzdem dort landen.
|
||||
if (widget.adresse != null && widget.adresse != alt.adresse) {
|
||||
setState(() => _subTab = 0);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 16, 20, 4),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text('Download',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
const Ueberschrift('Download'),
|
||||
SubTabs(
|
||||
labels: const ['YouTube', 'Server'],
|
||||
index: _subTab,
|
||||
onChanged: (i) => setState(() => _subTab = i),
|
||||
),
|
||||
Expanded(
|
||||
child: _subTab == 0 ? const _YouTubeBereich() : const _ServerBrowser(),
|
||||
child: _subTab == 0
|
||||
? _YouTubeBereich(
|
||||
adresse: widget.adresse,
|
||||
onAdresseUebernommen: widget.onAdresseUebernommen,
|
||||
)
|
||||
: const _ServerBrowser(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -541,7 +562,10 @@ const _cookiesSchluessel = 'yt_cookies';
|
||||
|
||||
/// YouTube-Bereich: Anmeldung am Baka-Konto und Adressfeld für den Download.
|
||||
class _YouTubeBereich extends StatefulWidget {
|
||||
const _YouTubeBereich();
|
||||
const _YouTubeBereich({this.adresse, this.onAdresseUebernommen});
|
||||
|
||||
final String? adresse;
|
||||
final VoidCallback? onAdresseUebernommen;
|
||||
|
||||
@override
|
||||
State<_YouTubeBereich> createState() => _YouTubeBereichState();
|
||||
@@ -562,6 +586,24 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ladeEinstellungen();
|
||||
_uebernimmAdresse();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_YouTubeBereich alt) {
|
||||
super.didUpdateWidget(alt);
|
||||
if (widget.adresse != null && widget.adresse != alt.adresse) {
|
||||
_uebernimmAdresse();
|
||||
}
|
||||
}
|
||||
|
||||
/// Trägt eine geteilte Adresse ins Feld — ohne den Download zu starten.
|
||||
/// Kategorie und Speicherort will man vorher noch wählen können.
|
||||
void _uebernimmAdresse() {
|
||||
final adresse = widget.adresse;
|
||||
if (adresse == null) return;
|
||||
_url.text = adresse;
|
||||
widget.onAdresseUebernommen?.call();
|
||||
}
|
||||
|
||||
Future<void> _ladeEinstellungen() async {
|
||||
@@ -845,3 +887,4 @@ class _KategorieFeld extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -461,6 +461,11 @@ class MeloDb extends _$MeloDb {
|
||||
));
|
||||
}
|
||||
|
||||
/// Der Song mit dieser UUID, oder `null`. Grundlage dafür, aus einer
|
||||
/// Adresse im Auto wieder einen Titel zu machen.
|
||||
Future<Song?> songById(String id) =>
|
||||
(select(songs)..where((s) => s.id.equals(id))).getSingleOrNull();
|
||||
|
||||
/// Der Song mit genau diesem Dateipfad, oder `null`.
|
||||
Future<Song?> songByPath(String path) =>
|
||||
(select(songs)..where((s) => s.path.equals(path))).getSingleOrNull();
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../shared/sort_store.dart';
|
||||
import '../shared/sortable_song_list.dart';
|
||||
import '../shared/ueberschrift.dart';
|
||||
import 'database.dart';
|
||||
import '../shared/theme.dart';
|
||||
|
||||
@@ -18,14 +19,7 @@ class FavoritesScreen extends StatelessWidget {
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 16, 20, 4),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text('Favoriten',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
const Ueberschrift('Favoriten'),
|
||||
Expanded(
|
||||
child: StreamBuilder<List<Song>>(
|
||||
stream: db.watchFavorites(),
|
||||
@@ -65,3 +59,4 @@ class _Empty extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../playlists/playlist_detail_screen.dart';
|
||||
import '../shared/theme.dart';
|
||||
import '../shared/ueberschrift.dart';
|
||||
import '../shared/titel_listen_screen.dart';
|
||||
import 'category_service.dart';
|
||||
import 'database.dart';
|
||||
@@ -104,14 +105,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 16, 20, 4),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text('Suchen',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
const Ueberschrift('Suchen'),
|
||||
_Suchfeld(
|
||||
controller: _feld,
|
||||
onChanged: _tippt,
|
||||
@@ -421,3 +415,4 @@ class _Hinweis extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
-1
@@ -22,6 +22,7 @@ import 'player/mini_player.dart';
|
||||
import 'services/baka_auth.dart';
|
||||
import 'services/logger_service.dart';
|
||||
import 'services/download_service.dart';
|
||||
import 'services/geteilte_adresse.dart';
|
||||
import 'services/melo_cloud_service.dart';
|
||||
import 'services/offline_mode.dart';
|
||||
import 'services/navidrome_service.dart';
|
||||
@@ -61,6 +62,12 @@ Future<void> main() async {
|
||||
androidNotificationChannelName: 'Melo',
|
||||
androidNotificationOngoing: true,
|
||||
androidShowNotificationBadge: true,
|
||||
// Android Auto zeigt die oberste Ebene als Reiter statt als Liste —
|
||||
// vier Ordner passen dort gut hin.
|
||||
androidBrowsableRootExtras: {
|
||||
'android.media.browse.CONTENT_STYLE_BROWSABLE_HINT': 2,
|
||||
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 1,
|
||||
},
|
||||
),
|
||||
);
|
||||
_effects = AudioEffects();
|
||||
@@ -143,6 +150,9 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||
int _index = 0;
|
||||
late final AutoScan _autoScan;
|
||||
|
||||
/// Adresse aus einem „Teilen"-Aufruf, die der Downloader übernehmen soll.
|
||||
String? _geteilteAdresse;
|
||||
|
||||
void _goTo(int index) => setState(() => _index = index);
|
||||
|
||||
@override
|
||||
@@ -160,6 +170,18 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||
// Geräten sind dann sofort da.
|
||||
context.read<SyncService>().automatisch();
|
||||
unawaited(_frageBenachrichtigungsRecht(context.read<MeloAudioHandler>()));
|
||||
unawaited(_pruefeGeteilteAdresse());
|
||||
}
|
||||
|
||||
/// Sieht nach, ob die App über „Teilen" mit einer Adresse geöffnet wurde,
|
||||
/// und wechselt dann in den Download-Bereich.
|
||||
Future<void> _pruefeGeteilteAdresse() async {
|
||||
final adresse = await const GeteilteAdresse().abholen();
|
||||
if (adresse == null || !mounted) return;
|
||||
setState(() {
|
||||
_geteilteAdresse = adresse;
|
||||
_index = 1;
|
||||
});
|
||||
}
|
||||
|
||||
/// Fragt das Recht auf die Wiedergabe-Benachrichtigung, sobald der erste
|
||||
@@ -192,6 +214,9 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
if (Platform.isAndroid) _autoScan.pruefe();
|
||||
context.read<SyncService>().automatisch();
|
||||
// Ein zweites Teilen startet die App nicht neu (singleTop), sondern
|
||||
// bringt sie nur nach vorn — deshalb auch hier nachsehen.
|
||||
unawaited(_pruefeGeteilteAdresse());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +227,10 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||
onSearchTap: () => _goTo(2),
|
||||
onFavoritesTap: () => _goTo(3),
|
||||
),
|
||||
const DownloadsScreen(),
|
||||
DownloadsScreen(
|
||||
adresse: _geteilteAdresse,
|
||||
onAdresseUebernommen: () => setState(() => _geteilteAdresse = null),
|
||||
),
|
||||
const SearchScreen(),
|
||||
const FavoritesScreen(),
|
||||
];
|
||||
|
||||
@@ -4,9 +4,12 @@ import 'package:audio_service/audio_service.dart';
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import '../library/database.dart';
|
||||
import '../library/song_grouping.dart';
|
||||
import '../library/song_media.dart';
|
||||
import '../services/cache_manager.dart';
|
||||
import '../services/download_speicher.dart';
|
||||
import '../services/navidrome_service.dart';
|
||||
import 'auto_browse.dart';
|
||||
import 'headphone_autoplay.dart';
|
||||
import 'replay_gain.dart';
|
||||
import 'sleep_timer.dart';
|
||||
@@ -455,6 +458,102 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
||||
await _player.moveAudioSource(oldIndex, newIndex);
|
||||
}
|
||||
|
||||
// === Blättern im Auto und beim Sprachassistenten ===
|
||||
//
|
||||
// Der MediaBrowserService war im Manifest zwar angemeldet, lieferte aber
|
||||
// nie Inhalte: ohne diese Methoden blieb die Bibliothek dort leer.
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> getChildren(String parentMediaId,
|
||||
[Map<String, dynamic>? options]) async {
|
||||
if (parentMediaId == AudioService.browsableRootId) {
|
||||
return [for (final art in wurzelArten) _ordner(art)];
|
||||
}
|
||||
final knoten = knotenAus(parentMediaId);
|
||||
if (knoten == null) return const [];
|
||||
switch (knoten.art) {
|
||||
case AutoArt.favoriten:
|
||||
return _alsMedien(await db.watchFavorites().first);
|
||||
case AutoArt.zuletzt:
|
||||
return _alsMedien(await db.watchRecent(limit: 100).first);
|
||||
case AutoArt.kategorien:
|
||||
final namen = await db.allCategoryNames();
|
||||
return [
|
||||
for (final name in namen) _ordner(AutoArt.kategorie, name),
|
||||
];
|
||||
case AutoArt.kuenstlerListe:
|
||||
final songs = await db.watchSongs().first;
|
||||
return [
|
||||
for (final name in groupByArtist(songs).keys)
|
||||
_ordner(AutoArt.kuenstler, name),
|
||||
];
|
||||
case AutoArt.kategorie:
|
||||
return _alsMedien(await _titelDerKategorie(knoten.name));
|
||||
case AutoArt.kuenstler:
|
||||
final songs = await db.watchSongs().first;
|
||||
return _alsMedien(groupByArtist(songs)[knoten.name] ?? const []);
|
||||
case AutoArt.titel:
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<MediaItem?> getMediaItem(String mediaId) async {
|
||||
final knoten = knotenAus(mediaId);
|
||||
if (knoten?.art != AutoArt.titel || knoten?.name == null) return null;
|
||||
final song = await db.songById(knoten!.name!);
|
||||
return song == null ? null : songToMediaItem(song);
|
||||
}
|
||||
|
||||
/// Wird aufgerufen, wenn im Auto ein Titel angetippt wird. Es läuft nicht
|
||||
/// nur dieser eine, sondern der Ordner ab dieser Stelle — sonst hörte die
|
||||
/// Musik nach einem Lied auf.
|
||||
@override
|
||||
Future<void> playFromMediaId(String mediaId,
|
||||
[Map<String, dynamic>? extras]) async {
|
||||
final knoten = knotenAus(mediaId);
|
||||
if (knoten == null) return;
|
||||
if (knoten.art == AutoArt.titel && knoten.name != null) {
|
||||
final ordner = extras?['ordner'] as String?;
|
||||
final geschwister =
|
||||
ordner == null ? const <MediaItem>[] : await getChildren(ordner);
|
||||
final index =
|
||||
geschwister.indexWhere((m) => songIdOf(m) == knoten.name);
|
||||
if (index != -1) {
|
||||
await loadPlaylist(geschwister, startIndex: index);
|
||||
return;
|
||||
}
|
||||
final song = await db.songById(knoten.name!);
|
||||
if (song != null) await loadPlaylist([songToMediaItem(song)]);
|
||||
return;
|
||||
}
|
||||
// Ein Ordner: alles darin von vorn.
|
||||
final inhalt = await getChildren(mediaId);
|
||||
final titel = [for (final m in inhalt) if (songIdOf(m) != null) m];
|
||||
if (titel.isNotEmpty) await loadPlaylist(titel);
|
||||
}
|
||||
|
||||
Future<List<Song>> _titelDerKategorie(String? name) async {
|
||||
if (name == null) return const [];
|
||||
final songs = await db.watchSongs().first;
|
||||
final zuordnung = await db.watchCategoriesBySong().first;
|
||||
return [
|
||||
for (final song in songs)
|
||||
if (zuordnung[song.id]?.contains(name) ?? false) song,
|
||||
];
|
||||
}
|
||||
|
||||
MediaItem _ordner(AutoArt art, [String? name]) => MediaItem(
|
||||
id: knotenId(art, name),
|
||||
title: name ?? art.anzeige,
|
||||
playable: false,
|
||||
);
|
||||
|
||||
List<MediaItem> _alsMedien(List<Song> songs) => [
|
||||
for (final song in songs)
|
||||
songToMediaItem(song).copyWith(id: knotenId(AutoArt.titel, song.id)),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> play() => _player.play();
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/// Was für ein Knoten im Browse-Baum von Android Auto und dem
|
||||
/// Sprachassistenten gemeint ist.
|
||||
enum AutoArt {
|
||||
favoriten('Favoriten'),
|
||||
zuletzt('Zuletzt hinzugefügt'),
|
||||
kategorien('Kategorien'),
|
||||
kuenstlerListe('Künstler'),
|
||||
kategorie(''),
|
||||
kuenstler(''),
|
||||
titel('');
|
||||
|
||||
const AutoArt(this.anzeige);
|
||||
|
||||
/// Beschriftung eines Ordners der obersten Ebene.
|
||||
final String anzeige;
|
||||
}
|
||||
|
||||
/// Die Ordner, die im Auto auf der obersten Ebene stehen — dieselben Wege,
|
||||
/// die auch die App anbietet.
|
||||
const wurzelArten = [
|
||||
AutoArt.favoriten,
|
||||
AutoArt.zuletzt,
|
||||
AutoArt.kategorien,
|
||||
AutoArt.kuenstlerListe,
|
||||
];
|
||||
|
||||
/// Ein aufgelöster Knoten: seine Art und, wo nötig, sein Name.
|
||||
class AutoKnoten {
|
||||
const AutoKnoten(this.art, this.name);
|
||||
|
||||
final AutoArt art;
|
||||
|
||||
/// Kategorie- oder Künstlername, bei einem Titel dessen UUID.
|
||||
final String? name;
|
||||
}
|
||||
|
||||
const _praefix = 'melo:';
|
||||
|
||||
/// Adresse eines Knotens.
|
||||
///
|
||||
/// Der Name steht **hinter** dem ersten Doppelpunkt nach der Art und wird
|
||||
/// nicht weiter zerlegt: „Intro: Der Anfang" ist ein normaler Albumtitel,
|
||||
/// und Schrägstriche kommen in Künstlernamen wie „AC/DC" vor. Würde hier
|
||||
/// gesplittet, verlöre man beides.
|
||||
String knotenId(AutoArt art, [String? name]) =>
|
||||
name == null ? '$_praefix${art.name}' : '$_praefix${art.name}:$name';
|
||||
|
||||
/// Löst eine Adresse wieder auf, oder `null`, wenn sie nicht von uns stammt.
|
||||
AutoKnoten? knotenAus(String mediaId) {
|
||||
if (!mediaId.startsWith(_praefix)) return null;
|
||||
final rest = mediaId.substring(_praefix.length);
|
||||
final trenner = rest.indexOf(':');
|
||||
final artName = trenner == -1 ? rest : rest.substring(0, trenner);
|
||||
final name = trenner == -1 ? null : rest.substring(trenner + 1);
|
||||
for (final art in AutoArt.values) {
|
||||
if (art.name == artName) return AutoKnoten(art, name);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import '../services/cover_cache.dart';
|
||||
import '../shared/theme.dart';
|
||||
|
||||
/// Wie viele der acht Bits je Farbkanal beim Einsortieren wegfallen —
|
||||
/// es bleiben also 5, das sind 32 Stufen je Kanal.
|
||||
///
|
||||
/// Ohne dieses Zusammenfassen hätte fast jeder Bildpunkt seine eigene Farbe
|
||||
/// und „die häufigste" wäre bedeutungslos.
|
||||
const _verworfeneBits = 3;
|
||||
|
||||
/// Bildpunkte unter dieser Deckkraft zählen nicht mit.
|
||||
const _mindestDeckkraft = 128;
|
||||
|
||||
/// Die prägende Farbe eines Coverbilds, aus rohen RGBA-Daten.
|
||||
///
|
||||
/// Nicht einfach die häufigste: Cover sind oft überwiegend dunkel oder grau,
|
||||
/// und dann sähe der Player für jeden Titel gleich aus. Häufigkeit **und**
|
||||
/// Farbigkeit entscheiden zusammen.
|
||||
///
|
||||
/// Rein und ohne Bild-Dekodierung — der Teil, der schiefgehen kann, lässt
|
||||
/// sich so für sich prüfen. Fällt auf [MeloTheme.red] zurück, wenn nichts
|
||||
/// Farbiges übrig bleibt (Schwarz-Weiß-Cover, leere Daten).
|
||||
Color dominanteFarbe(Uint8List rgba) {
|
||||
final anzahl = <int, int>{};
|
||||
// Vier Bytes je Bildpunkt; abgeschnittene Daten am Ende werden übergangen.
|
||||
for (var i = 0; i + 3 < rgba.length; i += 4) {
|
||||
if (rgba[i + 3] < _mindestDeckkraft) continue;
|
||||
final topf = (rgba[i] >> _verworfeneBits) << 16 |
|
||||
(rgba[i + 1] >> _verworfeneBits) << 8 |
|
||||
(rgba[i + 2] >> _verworfeneBits);
|
||||
anzahl[topf] = (anzahl[topf] ?? 0) + 1;
|
||||
}
|
||||
if (anzahl.isEmpty) return MeloTheme.red;
|
||||
|
||||
Color? beste;
|
||||
var bestesGewicht = 0.0;
|
||||
anzahl.forEach((topf, haeufigkeit) {
|
||||
final farbe = _ausTopf(topf);
|
||||
final hsv = HSVColor.fromColor(farbe);
|
||||
// Fast-Schwarz und Fast-Weiß tragen keine Aussage über den Charakter
|
||||
// eines Covers und fallen ganz heraus.
|
||||
if (hsv.value < 0.15 || (hsv.saturation < 0.15 && hsv.value > 0.9)) return;
|
||||
// Farbigkeit zählt doppelt: ein kleiner kräftiger Fleck sagt mehr über
|
||||
// ein Cover aus als eine große graue Fläche.
|
||||
final gewicht = haeufigkeit * (0.2 + hsv.saturation * 2);
|
||||
if (gewicht > bestesGewicht) {
|
||||
bestesGewicht = gewicht;
|
||||
beste = farbe;
|
||||
}
|
||||
});
|
||||
return beste ?? MeloTheme.red;
|
||||
}
|
||||
|
||||
Color _ausTopf(int topf) {
|
||||
// Die Mitte des Topfes statt seiner unteren Ecke — sonst wirkt jede Farbe
|
||||
// etwas zu dunkel.
|
||||
const mitte = 1 << (_verworfeneBits - 1);
|
||||
return Color.fromARGB(
|
||||
255,
|
||||
(((topf >> 16) & 0x1F) << _verworfeneBits) + mitte,
|
||||
(((topf >> 8) & 0x1F) << _verworfeneBits) + mitte,
|
||||
((topf & 0x1F) << _verworfeneBits) + mitte,
|
||||
);
|
||||
}
|
||||
|
||||
/// [farbe] so weit abgedunkelt, dass weiße Schrift darauf lesbar bleibt.
|
||||
///
|
||||
/// Ein knalliges Gelb als Hintergrund würde die Titelzeile verschlucken; die
|
||||
/// Markenfarben der App sind darauf ausgelegt, auf Dunklem zu stehen.
|
||||
Color lesbarAuf(Color farbe) {
|
||||
var hsl = HSLColor.fromColor(farbe);
|
||||
// Sättigung leicht zurücknehmen: ein voll gesättigter Hintergrund flimmert
|
||||
// hinter Text.
|
||||
hsl = hsl.withSaturation((hsl.saturation * 0.8).clamp(0.0, 1.0));
|
||||
var ergebnis = hsl.toColor();
|
||||
var versuche = 0;
|
||||
while (ergebnis.computeLuminance() > 0.12 && versuche < 12) {
|
||||
hsl = hsl.withLightness((hsl.lightness * 0.85).clamp(0.0, 1.0));
|
||||
ergebnis = hsl.toColor();
|
||||
versuche++;
|
||||
}
|
||||
return ergebnis;
|
||||
}
|
||||
|
||||
/// Holt die prägende Farbe eines Coverbilds.
|
||||
///
|
||||
/// Das Bild wird klein dekodiert (32 × 32): für die Farbwahl reicht das
|
||||
/// vollkommen, und ein volles Albumcover in Originalgröße zu dekodieren
|
||||
/// würde bei jedem Titelwechsel spürbar bremsen.
|
||||
Future<Color> farbeAusBild(ImageProvider bild) async {
|
||||
try {
|
||||
final daten = await _rohDaten(bild);
|
||||
return daten == null ? MeloTheme.red : dominanteFarbe(daten);
|
||||
} catch (e) {
|
||||
debugPrint('Cover-Farbe nicht ermittelbar: $e');
|
||||
return MeloTheme.red;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uint8List?> _rohDaten(ImageProvider bild) async {
|
||||
final vervollstaendigt = Completer<ui.Image>();
|
||||
final strom = bild.resolve(const ImageConfiguration(
|
||||
size: Size(32, 32),
|
||||
));
|
||||
late final ImageStreamListener zuhoerer;
|
||||
zuhoerer = ImageStreamListener(
|
||||
(info, _) {
|
||||
if (!vervollstaendigt.isCompleted) vervollstaendigt.complete(info.image);
|
||||
strom.removeListener(zuhoerer);
|
||||
},
|
||||
onError: (fehler, spur) {
|
||||
if (!vervollstaendigt.isCompleted) vervollstaendigt.completeError(fehler);
|
||||
strom.removeListener(zuhoerer);
|
||||
},
|
||||
);
|
||||
strom.addListener(zuhoerer);
|
||||
final bilddaten = await vervollstaendigt.future;
|
||||
final bytes =
|
||||
await bilddaten.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||||
return bytes?.buffer.asUint8List();
|
||||
}
|
||||
|
||||
/// Die Bildquelle hinter einer Cover-Adresse.
|
||||
///
|
||||
/// Server-Cover kommen über den Ordner (siehe [CoverCache]) und nicht direkt
|
||||
/// aus dem Netz: sonst würde die Farbwahl bei jedem Titelwechsel ein zweites
|
||||
/// Mal dasselbe Bild laden.
|
||||
Future<ImageProvider?> coverBildQuelle(Uri adresse) async {
|
||||
if (adresse.scheme == 'file') {
|
||||
final datei = File(adresse.toFilePath());
|
||||
return await datei.exists() ? FileImage(datei) : null;
|
||||
}
|
||||
if (adresse.scheme == 'http' || adresse.scheme == 'https') {
|
||||
final datei = await CoverCache.hole(adresse);
|
||||
return datei == null ? null : FileImage(datei);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -9,6 +11,7 @@ import '../shared/favorite_button.dart';
|
||||
import '../shared/server_favorite_button.dart';
|
||||
import '../shared/theme.dart';
|
||||
import 'audio_handler.dart';
|
||||
import 'cover_farbe.dart';
|
||||
import 'lrc.dart';
|
||||
import 'queue_screen.dart';
|
||||
|
||||
@@ -27,7 +30,12 @@ class NowPlayingScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final handler = context.read<MeloAudioHandler>();
|
||||
return Scaffold(
|
||||
return _CoverGrund(
|
||||
handler: handler,
|
||||
child: Scaffold(
|
||||
// Der Grund kommt aus dem Coverbild — deshalb hier durchsichtig.
|
||||
backgroundColor: Colors.transparent,
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
actions: [
|
||||
@@ -127,6 +135,79 @@ class NowPlayingScreen extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Färbt den Grund des Players nach dem Coverbild ein.
|
||||
///
|
||||
/// Der Player sah für jeden Titel gleich aus — schwarz. Ein Verlauf aus der
|
||||
/// prägenden Farbe des Covers gibt jedem Album sein eigenes Gesicht, ohne
|
||||
/// dass die Schrift darunter leidet: die Farbe wird vorher so weit
|
||||
/// abgedunkelt, dass Weiß darauf lesbar bleibt (siehe [lesbarAuf]).
|
||||
class _CoverGrund extends StatefulWidget {
|
||||
const _CoverGrund({required this.handler, required this.child});
|
||||
|
||||
final MeloAudioHandler handler;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<_CoverGrund> createState() => _CoverGrundState();
|
||||
}
|
||||
|
||||
class _CoverGrundState extends State<_CoverGrund> {
|
||||
Color _farbe = MeloTheme.black;
|
||||
Uri? _zuletzt;
|
||||
StreamSubscription<MediaItem?>? _abo;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_abo = widget.handler.mediaItem.listen(_uebernimm);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_abo?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _uebernimm(MediaItem? item) async {
|
||||
final art = item?.artUri;
|
||||
// Nur bei einem echten Bildwechsel neu rechnen: `mediaItem` meldet auch
|
||||
// nachgetragene Dauern, und jedes Mal ein Bild zu dekodieren wäre Unfug.
|
||||
if (art == _zuletzt) return;
|
||||
_zuletzt = art;
|
||||
if (art == null) {
|
||||
if (mounted) setState(() => _farbe = MeloTheme.black);
|
||||
return;
|
||||
}
|
||||
final bild = await coverBildQuelle(art);
|
||||
if (bild == null) {
|
||||
if (mounted) setState(() => _farbe = MeloTheme.black);
|
||||
return;
|
||||
}
|
||||
final farbe = lesbarAuf(await farbeAusBild(bild));
|
||||
if (mounted && art == _zuletzt) setState(() => _farbe = farbe);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedContainer(
|
||||
duration: MeloMotion.ruhig(context, MeloMotion.normal),
|
||||
curve: MeloMotion.curve,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
// Nach unten in Schwarz auslaufen: dort sitzen Fortschritt und
|
||||
// Bedienung, und die stehen auf dem gewohnten Grund am ruhigsten.
|
||||
colors: [_farbe, MeloTheme.black],
|
||||
stops: const [0.0, 0.75],
|
||||
),
|
||||
),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Erste Web-Adresse in [text], oder `null`.
|
||||
///
|
||||
/// Wer ein Video an Melo teilt, schickt selten nur die Adresse — die
|
||||
/// YouTube-App hängt Titel und „über @YouTube" drumherum.
|
||||
///
|
||||
/// Bewusst **nicht** auf youtube.com eingeschränkt: der Downloader läuft über
|
||||
/// yt-dlp am Server und kann mehr als YouTube. Was davon geht, entscheidet
|
||||
/// der Server, nicht diese Zeile.
|
||||
String? adresseAus(String? text) {
|
||||
if (text == null || text.isEmpty) return null;
|
||||
final treffer = _adresse.firstMatch(text);
|
||||
if (treffer == null) return null;
|
||||
// Satzzeichen am Ende gehören zum Satz, nicht zur Adresse.
|
||||
return treffer.group(0)!.replaceAll(RegExp(r'[.,;:!?)\]]+$'), '');
|
||||
}
|
||||
|
||||
final _adresse = RegExp(r'https?://\S+');
|
||||
|
||||
/// Nimmt entgegen, was andere Apps über „Teilen" an Melo schicken.
|
||||
///
|
||||
/// Eigene Brücke statt eines Zusatzpakets — das Projekt hat für MediaStore
|
||||
/// und Klangeffekte bereits eigene (siehe `MediaStoreBridge`).
|
||||
class GeteilteAdresse {
|
||||
const GeteilteAdresse({this.channel = const MethodChannel(kanalName)});
|
||||
|
||||
static const kanalName = 'de.baka.melo/geteilt';
|
||||
|
||||
final MethodChannel channel;
|
||||
|
||||
/// Die Adresse, mit der die App gerade geöffnet wurde, oder `null`.
|
||||
///
|
||||
/// Wird beim Abholen auf der Android-Seite gelöscht: sonst spränge die App
|
||||
/// bei jedem Wiedereintritt erneut in den Downloader.
|
||||
Future<String?> abholen() async {
|
||||
try {
|
||||
final text = await channel.invokeMethod<String>('hole');
|
||||
return adresseAus(text);
|
||||
} on PlatformException {
|
||||
return null;
|
||||
} on MissingPluginException {
|
||||
// Andere Plattformen kennen die Brücke nicht — dort gibt es kein
|
||||
// Teilen an Melo, und das ist kein Fehler.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
-10
@@ -93,28 +93,53 @@ class MeloTheme {
|
||||
/// Flächen sauberer als ein Schatten, den man ohnehin nicht sieht.
|
||||
static Border get hairline => Border.all(color: border, width: 1);
|
||||
|
||||
/// Die Stufenleiter der Schrift.
|
||||
///
|
||||
/// Melo bleibt bei der System-Schrift (auf Android Roboto): sie ist immer
|
||||
/// da, trägt jede Sprache und kostet nichts an App-Größe. Geschärft wird
|
||||
/// stattdessen, was ohne eigene Schrift geht — Größenabstände, Gewichte
|
||||
/// und vor allem die **Laufweite**: große Grade wirken mit den Standard-
|
||||
/// abständen auseinandergezogen, Großbuchstaben dagegen gedrängt.
|
||||
static TextTheme get _textTheme => const TextTheme(
|
||||
// Bildschirm-Überschrift ("Meine Musik", "Online").
|
||||
// Bildschirm-Überschrift ("Favoriten", "Download", "Suchen").
|
||||
// Etwas enger gesetzt, damit die Zeile als ein Wortbild steht.
|
||||
displaySmall: TextStyle(
|
||||
fontSize: 28, height: 1.15, fontWeight: FontWeight.w700, color: text1),
|
||||
fontSize: 26,
|
||||
height: 1.15,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.4,
|
||||
color: text1),
|
||||
// Titelleiste, Kopf eines Sheets.
|
||||
headlineSmall: TextStyle(
|
||||
fontSize: 22, height: 1.2, fontWeight: FontWeight.w700, color: text1),
|
||||
fontSize: 22,
|
||||
height: 1.2,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.3,
|
||||
color: text1),
|
||||
// Hervorgehobene Zeile ("Shuffle-Wiedergabe").
|
||||
titleMedium: TextStyle(
|
||||
fontSize: 17, height: 1.3, fontWeight: FontWeight.w600, color: text1),
|
||||
// Titel in einer Liste.
|
||||
bodyLarge: TextStyle(fontSize: 15, height: 1.4, color: text1),
|
||||
fontSize: 17,
|
||||
height: 1.3,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.1,
|
||||
color: text1),
|
||||
// Titel in einer Liste. Zeilenhöhe bleibt bei 1,4 — dass Fließtext
|
||||
// nicht enger gesetzt wird, hält `theme_kontrast_test.dart` als
|
||||
// Lesbarkeitsregel fest, und die ist älter als diese Politur.
|
||||
bodyLarge: TextStyle(
|
||||
fontSize: 15, height: 1.4, letterSpacing: 0, color: text1),
|
||||
// Untertitel in einer Liste.
|
||||
bodyMedium: TextStyle(fontSize: 13, height: 1.35, color: text2),
|
||||
bodyMedium: TextStyle(
|
||||
fontSize: 13, height: 1.35, letterSpacing: 0, color: text2),
|
||||
// Beiwerk: Anzahl, Dauer.
|
||||
bodySmall: TextStyle(fontSize: 12, height: 1.3, color: text3),
|
||||
// Abschnitts-Beschriftung, Knopftext.
|
||||
// Abschnitts-Beschriftung, Knopftext. Wird in Großbuchstaben
|
||||
// gesetzt — und die brauchen mehr Luft, nicht weniger.
|
||||
labelLarge: TextStyle(
|
||||
fontSize: 12,
|
||||
height: 1.2,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.4,
|
||||
letterSpacing: 0.6,
|
||||
color: text3),
|
||||
);
|
||||
|
||||
@@ -138,8 +163,14 @@ class MeloTheme {
|
||||
backgroundColor: black,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
centerTitle: false,
|
||||
// Dieselbe Stufe wie eine Überschrift — sonst hätte derselbe Rang
|
||||
// zwei verschiedene Schriftbilder.
|
||||
titleTextStyle: TextStyle(
|
||||
fontSize: 22, height: 1.2, fontWeight: FontWeight.w700, color: text1),
|
||||
fontSize: 22,
|
||||
height: 1.2,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.3,
|
||||
color: text1),
|
||||
),
|
||||
listTileTheme: const ListTileThemeData(
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: MeloSpace.md),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Überschrift eines Bildschirms ("Favoriten", "Download", "Suchen").
|
||||
///
|
||||
/// Nimmt die Stufe aus dem Theme ([TextTheme.displaySmall]) statt eigene
|
||||
/// Werte zu setzen. Vorher stand in drei Bildschirmen dieselbe von Hand
|
||||
/// gesetzte Größe, während die Stufe im Theme ungenutzt danebenlag — eine
|
||||
/// Änderung dort wäre nirgends angekommen.
|
||||
class Ueberschrift extends StatelessWidget {
|
||||
const Ueberschrift(this.text, {super.key});
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(text, style: Theme.of(context).textTheme.displaySmall),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:melo/player/auto_browse.dart';
|
||||
|
||||
/// Im Auto und beim Sprachassistenten blättert man durch die Bibliothek,
|
||||
/// statt die App zu bedienen. Jeder Knoten braucht dafür eine Adresse, die
|
||||
/// hin und zurück eindeutig ist.
|
||||
void main() {
|
||||
group('knotenId und knotenAus', () {
|
||||
test('eine Ordner-Adresse ohne Namen kommt unverändert zurück', () {
|
||||
final id = knotenId(AutoArt.favoriten);
|
||||
|
||||
final knoten = knotenAus(id);
|
||||
|
||||
expect(knoten!.art, AutoArt.favoriten);
|
||||
expect(knoten.name, isNull);
|
||||
});
|
||||
|
||||
test('ein Name übersteht den Weg hin und zurück', () {
|
||||
final id = knotenId(AutoArt.kategorie, 'Trap cool');
|
||||
|
||||
final knoten = knotenAus(id);
|
||||
|
||||
expect(knoten!.art, AutoArt.kategorie);
|
||||
expect(knoten.name, 'Trap cool');
|
||||
});
|
||||
|
||||
test('Doppelpunkte im Namen zerlegen die Adresse nicht', () {
|
||||
// "Intro: Der Anfang" ist ein völlig normaler Albumtitel — und der
|
||||
// Doppelpunkt ist zugleich unser Trennzeichen.
|
||||
final knoten = knotenAus(knotenId(AutoArt.kategorie, 'Intro: Anfang'));
|
||||
|
||||
expect(knoten!.name, 'Intro: Anfang');
|
||||
});
|
||||
|
||||
test('Schrägstriche und Umlaute überstehen es ebenfalls', () {
|
||||
const name = 'AC/DC — Küchenlieder';
|
||||
|
||||
expect(knotenAus(knotenId(AutoArt.kuenstler, name))!.name, name);
|
||||
});
|
||||
|
||||
test('ein Titel trägt seine Song-UUID', () {
|
||||
final knoten = knotenAus(knotenId(AutoArt.titel, 'uuid-1'));
|
||||
|
||||
expect(knoten!.art, AutoArt.titel);
|
||||
expect(knoten.name, 'uuid-1');
|
||||
});
|
||||
|
||||
test('eine fremde Adresse ergibt nichts', () {
|
||||
expect(knotenAus('irgendwas'), isNull);
|
||||
expect(knotenAus(''), isNull);
|
||||
expect(knotenAus('melo:gibtesnicht'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('wurzelArten', () {
|
||||
test('bietet die vier Wege an, die auch die App hat', () {
|
||||
expect(wurzelArten, [
|
||||
AutoArt.favoriten,
|
||||
AutoArt.zuletzt,
|
||||
AutoArt.kategorien,
|
||||
AutoArt.kuenstlerListe,
|
||||
]);
|
||||
});
|
||||
|
||||
test('jede Wurzel hat einen Namen für die Anzeige', () {
|
||||
for (final art in wurzelArten) {
|
||||
expect(art.anzeige, isNotEmpty);
|
||||
}
|
||||
});
|
||||
|
||||
test('die Wurzeln sind Ordner, keine Titel', () {
|
||||
// Ein Ordner darf nicht abspielbar sein, sonst versucht das Auto ihn
|
||||
// als Lied zu starten.
|
||||
for (final art in wurzelArten) {
|
||||
expect(art == AutoArt.titel, isFalse);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:melo/player/cover_farbe.dart';
|
||||
import 'package:melo/shared/theme.dart';
|
||||
|
||||
/// Baut ein RGBA-Bild aus Farben — je Farbe [wiederholungen] Bildpunkte.
|
||||
Uint8List _bild(List<(Color, int)> teile) {
|
||||
final bytes = <int>[];
|
||||
for (final (farbe, anzahl) in teile) {
|
||||
for (var i = 0; i < anzahl; i++) {
|
||||
bytes.addAll([
|
||||
(farbe.r * 255).round(),
|
||||
(farbe.g * 255).round(),
|
||||
(farbe.b * 255).round(),
|
||||
(farbe.a * 255).round(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
return Uint8List.fromList(bytes);
|
||||
}
|
||||
|
||||
/// Der Player war für jeden Titel gleich schwarz. Diese Regel holt die
|
||||
/// prägende Farbe aus dem Coverbild.
|
||||
void main() {
|
||||
group('dominanteFarbe', () {
|
||||
test('ein einfarbiges Bild ergibt seine Farbe', () {
|
||||
const rot = Color(0xFFD32F2F);
|
||||
|
||||
final farbe = dominanteFarbe(_bild([(rot, 100)]));
|
||||
|
||||
// Grob gerundet — die Farben werden in Töpfe einsortiert.
|
||||
expect((farbe.r * 255).round(), closeTo(211, 16));
|
||||
expect((farbe.g * 255).round(), closeTo(47, 16));
|
||||
expect((farbe.b * 255).round(), closeTo(47, 16));
|
||||
});
|
||||
|
||||
test('eine kräftige Farbe schlägt viel Grau', () {
|
||||
// Cover sind oft überwiegend dunkel oder grau. Die häufigste Farbe wäre
|
||||
// dann ein Grauton, und der Player sähe für jeden Titel gleich aus.
|
||||
final farbe = dominanteFarbe(_bild([
|
||||
(const Color(0xFF808080), 900),
|
||||
(const Color(0xFF00A0FF), 100),
|
||||
]));
|
||||
|
||||
expect(farbe.b, greaterThan(farbe.r));
|
||||
});
|
||||
|
||||
test('ein rein schwarzes Bild fällt auf die Markenfarbe zurück', () {
|
||||
expect(dominanteFarbe(_bild([(const Color(0xFF000000), 100)])),
|
||||
MeloTheme.red);
|
||||
});
|
||||
|
||||
test('ein rein weißes Bild fällt ebenfalls zurück', () {
|
||||
expect(dominanteFarbe(_bild([(const Color(0xFFFFFFFF), 100)])),
|
||||
MeloTheme.red);
|
||||
});
|
||||
|
||||
test('durchsichtige Bildpunkte zählen nicht mit', () {
|
||||
final farbe = dominanteFarbe(_bild([
|
||||
(const Color(0x0000FF00), 900),
|
||||
(const Color(0xFFD32F2F), 100),
|
||||
]));
|
||||
|
||||
expect(farbe.r, greaterThan(farbe.g));
|
||||
});
|
||||
|
||||
test('ein leeres Bild fällt auf die Markenfarbe zurück', () {
|
||||
expect(dominanteFarbe(Uint8List(0)), MeloTheme.red);
|
||||
});
|
||||
|
||||
test('unvollständige Bildpunkte am Ende stören nicht', () {
|
||||
// Abgeschnittene Daten dürfen keinen Bereichsfehler auslösen.
|
||||
final bytes = _bild([(const Color(0xFFD32F2F), 10)]).toList()
|
||||
..addAll([12, 34]);
|
||||
|
||||
expect(() => dominanteFarbe(Uint8List.fromList(bytes)), returnsNormally);
|
||||
});
|
||||
});
|
||||
|
||||
group('lesbarAuf', () {
|
||||
test('dunkle Farben bleiben dunkel genug für weiße Schrift', () {
|
||||
final farbe = lesbarAuf(const Color(0xFFFFEB3B));
|
||||
|
||||
// Ein knalliges Gelb muss abgedunkelt werden, sonst ist weiße Schrift
|
||||
// darauf nicht mehr lesbar.
|
||||
expect(farbe.computeLuminance(), lessThan(0.35));
|
||||
});
|
||||
|
||||
test('eine bereits dunkle Farbe bleibt weitgehend erhalten', () {
|
||||
const dunkel = Color(0xFF1A237E);
|
||||
|
||||
expect(lesbarAuf(dunkel).computeLuminance(),
|
||||
closeTo(dunkel.computeLuminance(), 0.1));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:melo/services/geteilte_adresse.dart';
|
||||
|
||||
/// Wer ein Video an Melo teilt, schickt selten nur die Adresse: die
|
||||
/// YouTube-App hängt Titel und Werbetext drumherum.
|
||||
void main() {
|
||||
group('adresseAus', () {
|
||||
test('nimmt eine nackte Adresse', () {
|
||||
expect(adresseAus('https://youtu.be/dQw4w9WgXcQ'),
|
||||
'https://youtu.be/dQw4w9WgXcQ');
|
||||
});
|
||||
|
||||
test('holt die Adresse aus dem Text der YouTube-App', () {
|
||||
const geteilt =
|
||||
'Schau dir das an: https://youtu.be/dQw4w9WgXcQ\n\nüber @YouTube';
|
||||
|
||||
expect(adresseAus(geteilt), 'https://youtu.be/dQw4w9WgXcQ');
|
||||
});
|
||||
|
||||
test('versteht die lange Form mit Parametern', () {
|
||||
const geteilt =
|
||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ&feature=shared';
|
||||
|
||||
expect(adresseAus(geteilt),
|
||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ&feature=shared');
|
||||
});
|
||||
|
||||
test('nimmt die erste Adresse, wenn mehrere drinstehen', () {
|
||||
const geteilt = 'https://youtu.be/eins und https://youtu.be/zwei';
|
||||
|
||||
expect(adresseAus(geteilt), 'https://youtu.be/eins');
|
||||
});
|
||||
|
||||
test('Satzzeichen am Ende gehören nicht zur Adresse', () {
|
||||
expect(adresseAus('Guck mal: https://youtu.be/abc.'),
|
||||
'https://youtu.be/abc');
|
||||
expect(adresseAus('(https://youtu.be/abc)'), 'https://youtu.be/abc');
|
||||
});
|
||||
|
||||
test('Text ohne Adresse ergibt nichts', () {
|
||||
expect(adresseAus('nur Text'), isNull);
|
||||
expect(adresseAus(''), isNull);
|
||||
expect(adresseAus(null), isNull);
|
||||
});
|
||||
|
||||
test('auch andere Adressen kommen durch — der Server entscheidet', () {
|
||||
// Der Downloader läuft über yt-dlp am Server und kann mehr als
|
||||
// YouTube. Hier auf youtube.com zu prüfen würde nur einschränken.
|
||||
expect(adresseAus('https://soundcloud.com/kuenstler/lied'),
|
||||
'https://soundcloud.com/kuenstler/lied');
|
||||
});
|
||||
|
||||
test('unverschlüsselte Adressen werden ebenfalls erkannt', () {
|
||||
expect(adresseAus('http://youtu.be/abc'), 'http://youtu.be/abc');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:melo/shared/theme.dart';
|
||||
|
||||
/// Die Bildschirm-Überschriften setzten ihre Größe von Hand, während das
|
||||
/// Theme eine eigene Stufe dafür vorhielt — die nie benutzt wurde. Diese
|
||||
/// Regeln halten die Stufenleiter zusammen.
|
||||
void main() {
|
||||
final texte = MeloTheme.dark.textTheme;
|
||||
|
||||
double groesse(TextStyle? s) => s!.fontSize!;
|
||||
double laufweite(TextStyle? s) => s!.letterSpacing ?? 0;
|
||||
|
||||
group('Stufenleiter', () {
|
||||
test('wird von oben nach unten kleiner', () {
|
||||
expect(groesse(texte.displaySmall),
|
||||
greaterThan(groesse(texte.headlineSmall)));
|
||||
expect(groesse(texte.headlineSmall),
|
||||
greaterThan(groesse(texte.titleMedium)));
|
||||
expect(groesse(texte.titleMedium), greaterThan(groesse(texte.bodyLarge)));
|
||||
expect(groesse(texte.bodyLarge), greaterThan(groesse(texte.bodyMedium)));
|
||||
expect(groesse(texte.bodyMedium), greaterThan(groesse(texte.bodySmall)));
|
||||
});
|
||||
|
||||
test('Fließtext bleibt bei mindestens 12', () {
|
||||
// Darunter wird es auf einem Telefon unlesbar, unabhängig vom Kontrast.
|
||||
expect(groesse(texte.bodySmall), greaterThanOrEqualTo(12));
|
||||
});
|
||||
});
|
||||
|
||||
group('Laufweite', () {
|
||||
test('große Überschriften stehen enger', () {
|
||||
// Bei großen Graden wirken die Abstände optisch zu weit; ein leicht
|
||||
// negativer Wert lässt die Zeile als Einheit lesen.
|
||||
expect(laufweite(texte.displaySmall), lessThan(0));
|
||||
expect(laufweite(texte.headlineSmall), lessThan(0));
|
||||
});
|
||||
|
||||
test('die Abschnitts-Beschriftung steht weiter', () {
|
||||
// Sie wird in Großbuchstaben gesetzt, und die brauchen mehr Luft.
|
||||
expect(laufweite(texte.labelLarge), greaterThan(0));
|
||||
});
|
||||
|
||||
test('Fließtext bleibt unangetastet', () {
|
||||
expect(laufweite(texte.bodyLarge), 0);
|
||||
expect(laufweite(texte.bodyMedium), 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('Gewichte', () {
|
||||
test('Überschriften sind kräftiger als Fließtext', () {
|
||||
expect(texte.displaySmall!.fontWeight!.value,
|
||||
greaterThan(texte.bodyLarge!.fontWeight?.value ?? 400));
|
||||
});
|
||||
});
|
||||
|
||||
test('die Titelleiste folgt derselben Stufe wie eine Überschrift', () {
|
||||
// Sonst hätte derselbe Rang zwei verschiedene Schriftbilder.
|
||||
final leiste = MeloTheme.dark.appBarTheme.titleTextStyle!;
|
||||
|
||||
expect(leiste.fontSize, groesse(texte.headlineSmall));
|
||||
expect(leiste.letterSpacing, laufweite(texte.headlineSmall));
|
||||
expect(leiste.fontWeight, texte.headlineSmall!.fontWeight);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user