diff --git a/.ui-design/reviews/melo_vollwertigkeit_20260823.md b/.ui-design/reviews/melo_vollwertigkeit_20260823.md
index 4809213..7f9772e 100644
--- a/.ui-design/reviews/melo_vollwertigkeit_20260823.md
+++ b/.ui-design/reviews/melo_vollwertigkeit_20260823.md
@@ -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).
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 626cc2b..b4d67c8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 91d88fd..6c5849a 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -48,12 +48,25 @@
+
+
+
+
+
+
+
+
{
+ // 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()
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/de/baka/melo/MainActivity.kt b/android/app/src/main/kotlin/de/baka/melo/MainActivity.kt
index ae742fa..a1ab9c2 100644
--- a/android/app/src/main/kotlin/de/baka/melo/MainActivity.kt
+++ b/android/app/src/main/kotlin/de/baka/melo/MainActivity.kt
@@ -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() {
diff --git a/android/app/src/main/res/xml/automotive_app_desc.xml b/android/app/src/main/res/xml/automotive_app_desc.xml
new file mode 100644
index 0000000..0779d7f
--- /dev/null
+++ b/android/app/src/main/res/xml/automotive_app_desc.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/lib/downloads/downloads_screen.dart b/lib/downloads/downloads_screen.dart
index 15fb815..a9bb54e 100644
--- a/lib/downloads/downloads_screen.dart
+++ b/lib/downloads/downloads_screen.dart
@@ -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 createState() => _DownloadsScreenState();
@@ -34,27 +47,35 @@ class _DownloadsScreenState extends State {
/// 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 _ladeEinstellungen() async {
@@ -845,3 +887,4 @@ class _KategorieFeld extends StatelessWidget {
);
}
}
+
diff --git a/lib/library/database.dart b/lib/library/database.dart
index c0f69ce..f3fc010 100644
--- a/lib/library/database.dart
+++ b/lib/library/database.dart
@@ -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 songById(String id) =>
+ (select(songs)..where((s) => s.id.equals(id))).getSingleOrNull();
+
/// Der Song mit genau diesem Dateipfad, oder `null`.
Future songByPath(String path) =>
(select(songs)..where((s) => s.path.equals(path))).getSingleOrNull();
diff --git a/lib/library/favorites_screen.dart b/lib/library/favorites_screen.dart
index f9bdffc..7d7f748 100644
--- a/lib/library/favorites_screen.dart
+++ b/lib/library/favorites_screen.dart
@@ -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>(
stream: db.watchFavorites(),
@@ -65,3 +59,4 @@ class _Empty extends StatelessWidget {
);
}
}
+
diff --git a/lib/library/search_screen.dart b/lib/library/search_screen.dart
index a528eba..7c3add2 100644
--- a/lib/library/search_screen.dart
+++ b/lib/library/search_screen.dart
@@ -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 {
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 {
);
}
}
+
diff --git a/lib/main.dart b/lib/main.dart
index 9974be0..5f3b29e 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -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 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 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 with WidgetsBindingObserver {
// Geräten sind dann sofort da.
context.read().automatisch();
unawaited(_frageBenachrichtigungsRecht(context.read()));
+ unawaited(_pruefeGeteilteAdresse());
+ }
+
+ /// Sieht nach, ob die App über „Teilen" mit einer Adresse geöffnet wurde,
+ /// und wechselt dann in den Download-Bereich.
+ Future _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 with WidgetsBindingObserver {
if (state == AppLifecycleState.resumed) {
if (Platform.isAndroid) _autoScan.pruefe();
context.read().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 with WidgetsBindingObserver {
onSearchTap: () => _goTo(2),
onFavoritesTap: () => _goTo(3),
),
- const DownloadsScreen(),
+ DownloadsScreen(
+ adresse: _geteilteAdresse,
+ onAdresseUebernommen: () => setState(() => _geteilteAdresse = null),
+ ),
const SearchScreen(),
const FavoritesScreen(),
];
diff --git a/lib/player/audio_handler.dart b/lib/player/audio_handler.dart
index 6e43e1b..bff047b 100644
--- a/lib/player/audio_handler.dart
+++ b/lib/player/audio_handler.dart
@@ -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> getChildren(String parentMediaId,
+ [Map? 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 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 playFromMediaId(String mediaId,
+ [Map? 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 [] : 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> _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 _alsMedien(List songs) => [
+ for (final song in songs)
+ songToMediaItem(song).copyWith(id: knotenId(AutoArt.titel, song.id)),
+ ];
+
@override
Future play() => _player.play();
diff --git a/lib/player/auto_browse.dart b/lib/player/auto_browse.dart
new file mode 100644
index 0000000..05f26c4
--- /dev/null
+++ b/lib/player/auto_browse.dart
@@ -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;
+}
diff --git a/lib/player/cover_farbe.dart b/lib/player/cover_farbe.dart
new file mode 100644
index 0000000..9f40827
--- /dev/null
+++ b/lib/player/cover_farbe.dart
@@ -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 = {};
+ // 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 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 _rohDaten(ImageProvider bild) async {
+ final vervollstaendigt = Completer();
+ 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 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;
+}
diff --git a/lib/player/now_playing_screen.dart b/lib/player/now_playing_screen.dart
index 52eebc9..a80dea4 100644
--- a/lib/player/now_playing_screen.dart
+++ b/lib/player/now_playing_screen.dart
@@ -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();
- 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? _abo;
+
+ @override
+ void initState() {
+ super.initState();
+ _abo = widget.handler.mediaItem.listen(_uebernimm);
+ }
+
+ @override
+ void dispose() {
+ _abo?.cancel();
+ super.dispose();
+ }
+
+ Future _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,
);
}
}
diff --git a/lib/services/geteilte_adresse.dart b/lib/services/geteilte_adresse.dart
new file mode 100644
index 0000000..a71f9db
--- /dev/null
+++ b/lib/services/geteilte_adresse.dart
@@ -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 abholen() async {
+ try {
+ final text = await channel.invokeMethod('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;
+ }
+ }
+}
diff --git a/lib/shared/theme.dart b/lib/shared/theme.dart
index d333e04..33d88a1 100644
--- a/lib/shared/theme.dart
+++ b/lib/shared/theme.dart
@@ -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),
diff --git a/lib/shared/ueberschrift.dart b/lib/shared/ueberschrift.dart
new file mode 100644
index 0000000..098dae9
--- /dev/null
+++ b/lib/shared/ueberschrift.dart
@@ -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),
+ ),
+ );
+ }
+}
diff --git a/test/player/auto_browse_test.dart b/test/player/auto_browse_test.dart
new file mode 100644
index 0000000..96cae05
--- /dev/null
+++ b/test/player/auto_browse_test.dart
@@ -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);
+ }
+ });
+ });
+}
diff --git a/test/player/cover_farbe_test.dart b/test/player/cover_farbe_test.dart
new file mode 100644
index 0000000..338195e
--- /dev/null
+++ b/test/player/cover_farbe_test.dart
@@ -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 = [];
+ 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));
+ });
+ });
+}
diff --git a/test/services/geteilte_adresse_test.dart b/test/services/geteilte_adresse_test.dart
new file mode 100644
index 0000000..82ee294
--- /dev/null
+++ b/test/services/geteilte_adresse_test.dart
@@ -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');
+ });
+ });
+}
diff --git a/test/shared/typografie_test.dart b/test/shared/typografie_test.dart
new file mode 100644
index 0000000..6d6e4f5
--- /dev/null
+++ b/test/shared/typografie_test.dart
@@ -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);
+ });
+}