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); + }); +}