Cover-Farbe, Teilen aus YouTube, Android Auto, geschärfte Typografie
P3-Liste aus dem UX-Review: Player nimmt Cover-Farbe auf, "Teilen" aus YouTube trägt die Adresse in den Downloader ein, Android Auto blättert durch die Bibliothek, Laufweite der Typografie geschärft, Überschriften vereinheitlicht. 533 Tests grün, flutter analyze ohne Befund.
This commit is contained in:
@@ -15,6 +15,7 @@ import '../services/navidrome_service.dart';
|
||||
import '../services/server_neuheiten.dart';
|
||||
import '../services/yt_download_service.dart';
|
||||
import '../shared/sub_tabs.dart';
|
||||
import '../shared/ueberschrift.dart';
|
||||
import 'download_einordnung.dart';
|
||||
import 'server_titel_screen.dart';
|
||||
import '../shared/theme.dart';
|
||||
@@ -24,7 +25,19 @@ import '../shared/theme.dart';
|
||||
/// Titel werden vom [MeloAudioHandler] automatisch lokal zwischengespeichert
|
||||
/// und stehen danach offline zur Verfügung.
|
||||
class DownloadsScreen extends StatefulWidget {
|
||||
const DownloadsScreen({super.key});
|
||||
const DownloadsScreen({
|
||||
super.key,
|
||||
this.adresse,
|
||||
this.onAdresseUebernommen,
|
||||
});
|
||||
|
||||
/// Adresse aus einem „Teilen"-Aufruf. Wird ins Eingabefeld übernommen und
|
||||
/// der YouTube-Unterreiter aufgeschlagen.
|
||||
final String? adresse;
|
||||
|
||||
/// Meldet zurück, dass die Adresse angekommen ist — sie soll beim nächsten
|
||||
/// Wechsel in diesen Bereich nicht erneut auftauchen.
|
||||
final VoidCallback? onAdresseUebernommen;
|
||||
|
||||
@override
|
||||
State<DownloadsScreen> createState() => _DownloadsScreenState();
|
||||
@@ -34,27 +47,35 @@ class _DownloadsScreenState extends State<DownloadsScreen> {
|
||||
/// 0 = YouTube, 1 = Server.
|
||||
int _subTab = 0;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(DownloadsScreen alt) {
|
||||
super.didUpdateWidget(alt);
|
||||
// Eine geteilte Adresse gehört zum YouTube-Bereich; wer gerade beim
|
||||
// Server war, soll trotzdem dort landen.
|
||||
if (widget.adresse != null && widget.adresse != alt.adresse) {
|
||||
setState(() => _subTab = 0);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 16, 20, 4),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text('Download',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
const Ueberschrift('Download'),
|
||||
SubTabs(
|
||||
labels: const ['YouTube', 'Server'],
|
||||
index: _subTab,
|
||||
onChanged: (i) => setState(() => _subTab = i),
|
||||
),
|
||||
Expanded(
|
||||
child: _subTab == 0 ? const _YouTubeBereich() : const _ServerBrowser(),
|
||||
child: _subTab == 0
|
||||
? _YouTubeBereich(
|
||||
adresse: widget.adresse,
|
||||
onAdresseUebernommen: widget.onAdresseUebernommen,
|
||||
)
|
||||
: const _ServerBrowser(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -541,7 +562,10 @@ const _cookiesSchluessel = 'yt_cookies';
|
||||
|
||||
/// YouTube-Bereich: Anmeldung am Baka-Konto und Adressfeld für den Download.
|
||||
class _YouTubeBereich extends StatefulWidget {
|
||||
const _YouTubeBereich();
|
||||
const _YouTubeBereich({this.adresse, this.onAdresseUebernommen});
|
||||
|
||||
final String? adresse;
|
||||
final VoidCallback? onAdresseUebernommen;
|
||||
|
||||
@override
|
||||
State<_YouTubeBereich> createState() => _YouTubeBereichState();
|
||||
@@ -562,6 +586,24 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ladeEinstellungen();
|
||||
_uebernimmAdresse();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_YouTubeBereich alt) {
|
||||
super.didUpdateWidget(alt);
|
||||
if (widget.adresse != null && widget.adresse != alt.adresse) {
|
||||
_uebernimmAdresse();
|
||||
}
|
||||
}
|
||||
|
||||
/// Trägt eine geteilte Adresse ins Feld — ohne den Download zu starten.
|
||||
/// Kategorie und Speicherort will man vorher noch wählen können.
|
||||
void _uebernimmAdresse() {
|
||||
final adresse = widget.adresse;
|
||||
if (adresse == null) return;
|
||||
_url.text = adresse;
|
||||
widget.onAdresseUebernommen?.call();
|
||||
}
|
||||
|
||||
Future<void> _ladeEinstellungen() async {
|
||||
@@ -845,3 +887,4 @@ class _KategorieFeld extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -461,6 +461,11 @@ class MeloDb extends _$MeloDb {
|
||||
));
|
||||
}
|
||||
|
||||
/// Der Song mit dieser UUID, oder `null`. Grundlage dafür, aus einer
|
||||
/// Adresse im Auto wieder einen Titel zu machen.
|
||||
Future<Song?> songById(String id) =>
|
||||
(select(songs)..where((s) => s.id.equals(id))).getSingleOrNull();
|
||||
|
||||
/// Der Song mit genau diesem Dateipfad, oder `null`.
|
||||
Future<Song?> songByPath(String path) =>
|
||||
(select(songs)..where((s) => s.path.equals(path))).getSingleOrNull();
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../shared/sort_store.dart';
|
||||
import '../shared/sortable_song_list.dart';
|
||||
import '../shared/ueberschrift.dart';
|
||||
import 'database.dart';
|
||||
import '../shared/theme.dart';
|
||||
|
||||
@@ -18,14 +19,7 @@ class FavoritesScreen extends StatelessWidget {
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 16, 20, 4),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text('Favoriten',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
const Ueberschrift('Favoriten'),
|
||||
Expanded(
|
||||
child: StreamBuilder<List<Song>>(
|
||||
stream: db.watchFavorites(),
|
||||
@@ -65,3 +59,4 @@ class _Empty extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../playlists/playlist_detail_screen.dart';
|
||||
import '../shared/theme.dart';
|
||||
import '../shared/ueberschrift.dart';
|
||||
import '../shared/titel_listen_screen.dart';
|
||||
import 'category_service.dart';
|
||||
import 'database.dart';
|
||||
@@ -104,14 +105,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 16, 20, 4),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text('Suchen',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
const Ueberschrift('Suchen'),
|
||||
_Suchfeld(
|
||||
controller: _feld,
|
||||
onChanged: _tippt,
|
||||
@@ -421,3 +415,4 @@ class _Hinweis extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
-1
@@ -22,6 +22,7 @@ import 'player/mini_player.dart';
|
||||
import 'services/baka_auth.dart';
|
||||
import 'services/logger_service.dart';
|
||||
import 'services/download_service.dart';
|
||||
import 'services/geteilte_adresse.dart';
|
||||
import 'services/melo_cloud_service.dart';
|
||||
import 'services/offline_mode.dart';
|
||||
import 'services/navidrome_service.dart';
|
||||
@@ -61,6 +62,12 @@ Future<void> main() async {
|
||||
androidNotificationChannelName: 'Melo',
|
||||
androidNotificationOngoing: true,
|
||||
androidShowNotificationBadge: true,
|
||||
// Android Auto zeigt die oberste Ebene als Reiter statt als Liste —
|
||||
// vier Ordner passen dort gut hin.
|
||||
androidBrowsableRootExtras: {
|
||||
'android.media.browse.CONTENT_STYLE_BROWSABLE_HINT': 2,
|
||||
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 1,
|
||||
},
|
||||
),
|
||||
);
|
||||
_effects = AudioEffects();
|
||||
@@ -143,6 +150,9 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||
int _index = 0;
|
||||
late final AutoScan _autoScan;
|
||||
|
||||
/// Adresse aus einem „Teilen"-Aufruf, die der Downloader übernehmen soll.
|
||||
String? _geteilteAdresse;
|
||||
|
||||
void _goTo(int index) => setState(() => _index = index);
|
||||
|
||||
@override
|
||||
@@ -160,6 +170,18 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||
// Geräten sind dann sofort da.
|
||||
context.read<SyncService>().automatisch();
|
||||
unawaited(_frageBenachrichtigungsRecht(context.read<MeloAudioHandler>()));
|
||||
unawaited(_pruefeGeteilteAdresse());
|
||||
}
|
||||
|
||||
/// Sieht nach, ob die App über „Teilen" mit einer Adresse geöffnet wurde,
|
||||
/// und wechselt dann in den Download-Bereich.
|
||||
Future<void> _pruefeGeteilteAdresse() async {
|
||||
final adresse = await const GeteilteAdresse().abholen();
|
||||
if (adresse == null || !mounted) return;
|
||||
setState(() {
|
||||
_geteilteAdresse = adresse;
|
||||
_index = 1;
|
||||
});
|
||||
}
|
||||
|
||||
/// Fragt das Recht auf die Wiedergabe-Benachrichtigung, sobald der erste
|
||||
@@ -192,6 +214,9 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
if (Platform.isAndroid) _autoScan.pruefe();
|
||||
context.read<SyncService>().automatisch();
|
||||
// Ein zweites Teilen startet die App nicht neu (singleTop), sondern
|
||||
// bringt sie nur nach vorn — deshalb auch hier nachsehen.
|
||||
unawaited(_pruefeGeteilteAdresse());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +227,10 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
||||
onSearchTap: () => _goTo(2),
|
||||
onFavoritesTap: () => _goTo(3),
|
||||
),
|
||||
const DownloadsScreen(),
|
||||
DownloadsScreen(
|
||||
adresse: _geteilteAdresse,
|
||||
onAdresseUebernommen: () => setState(() => _geteilteAdresse = null),
|
||||
),
|
||||
const SearchScreen(),
|
||||
const FavoritesScreen(),
|
||||
];
|
||||
|
||||
@@ -4,9 +4,12 @@ import 'package:audio_service/audio_service.dart';
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import '../library/database.dart';
|
||||
import '../library/song_grouping.dart';
|
||||
import '../library/song_media.dart';
|
||||
import '../services/cache_manager.dart';
|
||||
import '../services/download_speicher.dart';
|
||||
import '../services/navidrome_service.dart';
|
||||
import 'auto_browse.dart';
|
||||
import 'headphone_autoplay.dart';
|
||||
import 'replay_gain.dart';
|
||||
import 'sleep_timer.dart';
|
||||
@@ -455,6 +458,102 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
||||
await _player.moveAudioSource(oldIndex, newIndex);
|
||||
}
|
||||
|
||||
// === Blättern im Auto und beim Sprachassistenten ===
|
||||
//
|
||||
// Der MediaBrowserService war im Manifest zwar angemeldet, lieferte aber
|
||||
// nie Inhalte: ohne diese Methoden blieb die Bibliothek dort leer.
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> getChildren(String parentMediaId,
|
||||
[Map<String, dynamic>? options]) async {
|
||||
if (parentMediaId == AudioService.browsableRootId) {
|
||||
return [for (final art in wurzelArten) _ordner(art)];
|
||||
}
|
||||
final knoten = knotenAus(parentMediaId);
|
||||
if (knoten == null) return const [];
|
||||
switch (knoten.art) {
|
||||
case AutoArt.favoriten:
|
||||
return _alsMedien(await db.watchFavorites().first);
|
||||
case AutoArt.zuletzt:
|
||||
return _alsMedien(await db.watchRecent(limit: 100).first);
|
||||
case AutoArt.kategorien:
|
||||
final namen = await db.allCategoryNames();
|
||||
return [
|
||||
for (final name in namen) _ordner(AutoArt.kategorie, name),
|
||||
];
|
||||
case AutoArt.kuenstlerListe:
|
||||
final songs = await db.watchSongs().first;
|
||||
return [
|
||||
for (final name in groupByArtist(songs).keys)
|
||||
_ordner(AutoArt.kuenstler, name),
|
||||
];
|
||||
case AutoArt.kategorie:
|
||||
return _alsMedien(await _titelDerKategorie(knoten.name));
|
||||
case AutoArt.kuenstler:
|
||||
final songs = await db.watchSongs().first;
|
||||
return _alsMedien(groupByArtist(songs)[knoten.name] ?? const []);
|
||||
case AutoArt.titel:
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<MediaItem?> getMediaItem(String mediaId) async {
|
||||
final knoten = knotenAus(mediaId);
|
||||
if (knoten?.art != AutoArt.titel || knoten?.name == null) return null;
|
||||
final song = await db.songById(knoten!.name!);
|
||||
return song == null ? null : songToMediaItem(song);
|
||||
}
|
||||
|
||||
/// Wird aufgerufen, wenn im Auto ein Titel angetippt wird. Es läuft nicht
|
||||
/// nur dieser eine, sondern der Ordner ab dieser Stelle — sonst hörte die
|
||||
/// Musik nach einem Lied auf.
|
||||
@override
|
||||
Future<void> playFromMediaId(String mediaId,
|
||||
[Map<String, dynamic>? extras]) async {
|
||||
final knoten = knotenAus(mediaId);
|
||||
if (knoten == null) return;
|
||||
if (knoten.art == AutoArt.titel && knoten.name != null) {
|
||||
final ordner = extras?['ordner'] as String?;
|
||||
final geschwister =
|
||||
ordner == null ? const <MediaItem>[] : await getChildren(ordner);
|
||||
final index =
|
||||
geschwister.indexWhere((m) => songIdOf(m) == knoten.name);
|
||||
if (index != -1) {
|
||||
await loadPlaylist(geschwister, startIndex: index);
|
||||
return;
|
||||
}
|
||||
final song = await db.songById(knoten.name!);
|
||||
if (song != null) await loadPlaylist([songToMediaItem(song)]);
|
||||
return;
|
||||
}
|
||||
// Ein Ordner: alles darin von vorn.
|
||||
final inhalt = await getChildren(mediaId);
|
||||
final titel = [for (final m in inhalt) if (songIdOf(m) != null) m];
|
||||
if (titel.isNotEmpty) await loadPlaylist(titel);
|
||||
}
|
||||
|
||||
Future<List<Song>> _titelDerKategorie(String? name) async {
|
||||
if (name == null) return const [];
|
||||
final songs = await db.watchSongs().first;
|
||||
final zuordnung = await db.watchCategoriesBySong().first;
|
||||
return [
|
||||
for (final song in songs)
|
||||
if (zuordnung[song.id]?.contains(name) ?? false) song,
|
||||
];
|
||||
}
|
||||
|
||||
MediaItem _ordner(AutoArt art, [String? name]) => MediaItem(
|
||||
id: knotenId(art, name),
|
||||
title: name ?? art.anzeige,
|
||||
playable: false,
|
||||
);
|
||||
|
||||
List<MediaItem> _alsMedien(List<Song> songs) => [
|
||||
for (final song in songs)
|
||||
songToMediaItem(song).copyWith(id: knotenId(AutoArt.titel, song.id)),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> play() => _player.play();
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/// Was für ein Knoten im Browse-Baum von Android Auto und dem
|
||||
/// Sprachassistenten gemeint ist.
|
||||
enum AutoArt {
|
||||
favoriten('Favoriten'),
|
||||
zuletzt('Zuletzt hinzugefügt'),
|
||||
kategorien('Kategorien'),
|
||||
kuenstlerListe('Künstler'),
|
||||
kategorie(''),
|
||||
kuenstler(''),
|
||||
titel('');
|
||||
|
||||
const AutoArt(this.anzeige);
|
||||
|
||||
/// Beschriftung eines Ordners der obersten Ebene.
|
||||
final String anzeige;
|
||||
}
|
||||
|
||||
/// Die Ordner, die im Auto auf der obersten Ebene stehen — dieselben Wege,
|
||||
/// die auch die App anbietet.
|
||||
const wurzelArten = [
|
||||
AutoArt.favoriten,
|
||||
AutoArt.zuletzt,
|
||||
AutoArt.kategorien,
|
||||
AutoArt.kuenstlerListe,
|
||||
];
|
||||
|
||||
/// Ein aufgelöster Knoten: seine Art und, wo nötig, sein Name.
|
||||
class AutoKnoten {
|
||||
const AutoKnoten(this.art, this.name);
|
||||
|
||||
final AutoArt art;
|
||||
|
||||
/// Kategorie- oder Künstlername, bei einem Titel dessen UUID.
|
||||
final String? name;
|
||||
}
|
||||
|
||||
const _praefix = 'melo:';
|
||||
|
||||
/// Adresse eines Knotens.
|
||||
///
|
||||
/// Der Name steht **hinter** dem ersten Doppelpunkt nach der Art und wird
|
||||
/// nicht weiter zerlegt: „Intro: Der Anfang" ist ein normaler Albumtitel,
|
||||
/// und Schrägstriche kommen in Künstlernamen wie „AC/DC" vor. Würde hier
|
||||
/// gesplittet, verlöre man beides.
|
||||
String knotenId(AutoArt art, [String? name]) =>
|
||||
name == null ? '$_praefix${art.name}' : '$_praefix${art.name}:$name';
|
||||
|
||||
/// Löst eine Adresse wieder auf, oder `null`, wenn sie nicht von uns stammt.
|
||||
AutoKnoten? knotenAus(String mediaId) {
|
||||
if (!mediaId.startsWith(_praefix)) return null;
|
||||
final rest = mediaId.substring(_praefix.length);
|
||||
final trenner = rest.indexOf(':');
|
||||
final artName = trenner == -1 ? rest : rest.substring(0, trenner);
|
||||
final name = trenner == -1 ? null : rest.substring(trenner + 1);
|
||||
for (final art in AutoArt.values) {
|
||||
if (art.name == artName) return AutoKnoten(art, name);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import '../services/cover_cache.dart';
|
||||
import '../shared/theme.dart';
|
||||
|
||||
/// Wie viele der acht Bits je Farbkanal beim Einsortieren wegfallen —
|
||||
/// es bleiben also 5, das sind 32 Stufen je Kanal.
|
||||
///
|
||||
/// Ohne dieses Zusammenfassen hätte fast jeder Bildpunkt seine eigene Farbe
|
||||
/// und „die häufigste" wäre bedeutungslos.
|
||||
const _verworfeneBits = 3;
|
||||
|
||||
/// Bildpunkte unter dieser Deckkraft zählen nicht mit.
|
||||
const _mindestDeckkraft = 128;
|
||||
|
||||
/// Die prägende Farbe eines Coverbilds, aus rohen RGBA-Daten.
|
||||
///
|
||||
/// Nicht einfach die häufigste: Cover sind oft überwiegend dunkel oder grau,
|
||||
/// und dann sähe der Player für jeden Titel gleich aus. Häufigkeit **und**
|
||||
/// Farbigkeit entscheiden zusammen.
|
||||
///
|
||||
/// Rein und ohne Bild-Dekodierung — der Teil, der schiefgehen kann, lässt
|
||||
/// sich so für sich prüfen. Fällt auf [MeloTheme.red] zurück, wenn nichts
|
||||
/// Farbiges übrig bleibt (Schwarz-Weiß-Cover, leere Daten).
|
||||
Color dominanteFarbe(Uint8List rgba) {
|
||||
final anzahl = <int, int>{};
|
||||
// Vier Bytes je Bildpunkt; abgeschnittene Daten am Ende werden übergangen.
|
||||
for (var i = 0; i + 3 < rgba.length; i += 4) {
|
||||
if (rgba[i + 3] < _mindestDeckkraft) continue;
|
||||
final topf = (rgba[i] >> _verworfeneBits) << 16 |
|
||||
(rgba[i + 1] >> _verworfeneBits) << 8 |
|
||||
(rgba[i + 2] >> _verworfeneBits);
|
||||
anzahl[topf] = (anzahl[topf] ?? 0) + 1;
|
||||
}
|
||||
if (anzahl.isEmpty) return MeloTheme.red;
|
||||
|
||||
Color? beste;
|
||||
var bestesGewicht = 0.0;
|
||||
anzahl.forEach((topf, haeufigkeit) {
|
||||
final farbe = _ausTopf(topf);
|
||||
final hsv = HSVColor.fromColor(farbe);
|
||||
// Fast-Schwarz und Fast-Weiß tragen keine Aussage über den Charakter
|
||||
// eines Covers und fallen ganz heraus.
|
||||
if (hsv.value < 0.15 || (hsv.saturation < 0.15 && hsv.value > 0.9)) return;
|
||||
// Farbigkeit zählt doppelt: ein kleiner kräftiger Fleck sagt mehr über
|
||||
// ein Cover aus als eine große graue Fläche.
|
||||
final gewicht = haeufigkeit * (0.2 + hsv.saturation * 2);
|
||||
if (gewicht > bestesGewicht) {
|
||||
bestesGewicht = gewicht;
|
||||
beste = farbe;
|
||||
}
|
||||
});
|
||||
return beste ?? MeloTheme.red;
|
||||
}
|
||||
|
||||
Color _ausTopf(int topf) {
|
||||
// Die Mitte des Topfes statt seiner unteren Ecke — sonst wirkt jede Farbe
|
||||
// etwas zu dunkel.
|
||||
const mitte = 1 << (_verworfeneBits - 1);
|
||||
return Color.fromARGB(
|
||||
255,
|
||||
(((topf >> 16) & 0x1F) << _verworfeneBits) + mitte,
|
||||
(((topf >> 8) & 0x1F) << _verworfeneBits) + mitte,
|
||||
((topf & 0x1F) << _verworfeneBits) + mitte,
|
||||
);
|
||||
}
|
||||
|
||||
/// [farbe] so weit abgedunkelt, dass weiße Schrift darauf lesbar bleibt.
|
||||
///
|
||||
/// Ein knalliges Gelb als Hintergrund würde die Titelzeile verschlucken; die
|
||||
/// Markenfarben der App sind darauf ausgelegt, auf Dunklem zu stehen.
|
||||
Color lesbarAuf(Color farbe) {
|
||||
var hsl = HSLColor.fromColor(farbe);
|
||||
// Sättigung leicht zurücknehmen: ein voll gesättigter Hintergrund flimmert
|
||||
// hinter Text.
|
||||
hsl = hsl.withSaturation((hsl.saturation * 0.8).clamp(0.0, 1.0));
|
||||
var ergebnis = hsl.toColor();
|
||||
var versuche = 0;
|
||||
while (ergebnis.computeLuminance() > 0.12 && versuche < 12) {
|
||||
hsl = hsl.withLightness((hsl.lightness * 0.85).clamp(0.0, 1.0));
|
||||
ergebnis = hsl.toColor();
|
||||
versuche++;
|
||||
}
|
||||
return ergebnis;
|
||||
}
|
||||
|
||||
/// Holt die prägende Farbe eines Coverbilds.
|
||||
///
|
||||
/// Das Bild wird klein dekodiert (32 × 32): für die Farbwahl reicht das
|
||||
/// vollkommen, und ein volles Albumcover in Originalgröße zu dekodieren
|
||||
/// würde bei jedem Titelwechsel spürbar bremsen.
|
||||
Future<Color> farbeAusBild(ImageProvider bild) async {
|
||||
try {
|
||||
final daten = await _rohDaten(bild);
|
||||
return daten == null ? MeloTheme.red : dominanteFarbe(daten);
|
||||
} catch (e) {
|
||||
debugPrint('Cover-Farbe nicht ermittelbar: $e');
|
||||
return MeloTheme.red;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uint8List?> _rohDaten(ImageProvider bild) async {
|
||||
final vervollstaendigt = Completer<ui.Image>();
|
||||
final strom = bild.resolve(const ImageConfiguration(
|
||||
size: Size(32, 32),
|
||||
));
|
||||
late final ImageStreamListener zuhoerer;
|
||||
zuhoerer = ImageStreamListener(
|
||||
(info, _) {
|
||||
if (!vervollstaendigt.isCompleted) vervollstaendigt.complete(info.image);
|
||||
strom.removeListener(zuhoerer);
|
||||
},
|
||||
onError: (fehler, spur) {
|
||||
if (!vervollstaendigt.isCompleted) vervollstaendigt.completeError(fehler);
|
||||
strom.removeListener(zuhoerer);
|
||||
},
|
||||
);
|
||||
strom.addListener(zuhoerer);
|
||||
final bilddaten = await vervollstaendigt.future;
|
||||
final bytes =
|
||||
await bilddaten.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||||
return bytes?.buffer.asUint8List();
|
||||
}
|
||||
|
||||
/// Die Bildquelle hinter einer Cover-Adresse.
|
||||
///
|
||||
/// Server-Cover kommen über den Ordner (siehe [CoverCache]) und nicht direkt
|
||||
/// aus dem Netz: sonst würde die Farbwahl bei jedem Titelwechsel ein zweites
|
||||
/// Mal dasselbe Bild laden.
|
||||
Future<ImageProvider?> coverBildQuelle(Uri adresse) async {
|
||||
if (adresse.scheme == 'file') {
|
||||
final datei = File(adresse.toFilePath());
|
||||
return await datei.exists() ? FileImage(datei) : null;
|
||||
}
|
||||
if (adresse.scheme == 'http' || adresse.scheme == 'https') {
|
||||
final datei = await CoverCache.hole(adresse);
|
||||
return datei == null ? null : FileImage(datei);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -9,6 +11,7 @@ import '../shared/favorite_button.dart';
|
||||
import '../shared/server_favorite_button.dart';
|
||||
import '../shared/theme.dart';
|
||||
import 'audio_handler.dart';
|
||||
import 'cover_farbe.dart';
|
||||
import 'lrc.dart';
|
||||
import 'queue_screen.dart';
|
||||
|
||||
@@ -27,7 +30,12 @@ class NowPlayingScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final handler = context.read<MeloAudioHandler>();
|
||||
return Scaffold(
|
||||
return _CoverGrund(
|
||||
handler: handler,
|
||||
child: Scaffold(
|
||||
// Der Grund kommt aus dem Coverbild — deshalb hier durchsichtig.
|
||||
backgroundColor: Colors.transparent,
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
actions: [
|
||||
@@ -127,6 +135,79 @@ class NowPlayingScreen extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Färbt den Grund des Players nach dem Coverbild ein.
|
||||
///
|
||||
/// Der Player sah für jeden Titel gleich aus — schwarz. Ein Verlauf aus der
|
||||
/// prägenden Farbe des Covers gibt jedem Album sein eigenes Gesicht, ohne
|
||||
/// dass die Schrift darunter leidet: die Farbe wird vorher so weit
|
||||
/// abgedunkelt, dass Weiß darauf lesbar bleibt (siehe [lesbarAuf]).
|
||||
class _CoverGrund extends StatefulWidget {
|
||||
const _CoverGrund({required this.handler, required this.child});
|
||||
|
||||
final MeloAudioHandler handler;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<_CoverGrund> createState() => _CoverGrundState();
|
||||
}
|
||||
|
||||
class _CoverGrundState extends State<_CoverGrund> {
|
||||
Color _farbe = MeloTheme.black;
|
||||
Uri? _zuletzt;
|
||||
StreamSubscription<MediaItem?>? _abo;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_abo = widget.handler.mediaItem.listen(_uebernimm);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_abo?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _uebernimm(MediaItem? item) async {
|
||||
final art = item?.artUri;
|
||||
// Nur bei einem echten Bildwechsel neu rechnen: `mediaItem` meldet auch
|
||||
// nachgetragene Dauern, und jedes Mal ein Bild zu dekodieren wäre Unfug.
|
||||
if (art == _zuletzt) return;
|
||||
_zuletzt = art;
|
||||
if (art == null) {
|
||||
if (mounted) setState(() => _farbe = MeloTheme.black);
|
||||
return;
|
||||
}
|
||||
final bild = await coverBildQuelle(art);
|
||||
if (bild == null) {
|
||||
if (mounted) setState(() => _farbe = MeloTheme.black);
|
||||
return;
|
||||
}
|
||||
final farbe = lesbarAuf(await farbeAusBild(bild));
|
||||
if (mounted && art == _zuletzt) setState(() => _farbe = farbe);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedContainer(
|
||||
duration: MeloMotion.ruhig(context, MeloMotion.normal),
|
||||
curve: MeloMotion.curve,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
// Nach unten in Schwarz auslaufen: dort sitzen Fortschritt und
|
||||
// Bedienung, und die stehen auf dem gewohnten Grund am ruhigsten.
|
||||
colors: [_farbe, MeloTheme.black],
|
||||
stops: const [0.0, 0.75],
|
||||
),
|
||||
),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Erste Web-Adresse in [text], oder `null`.
|
||||
///
|
||||
/// Wer ein Video an Melo teilt, schickt selten nur die Adresse — die
|
||||
/// YouTube-App hängt Titel und „über @YouTube" drumherum.
|
||||
///
|
||||
/// Bewusst **nicht** auf youtube.com eingeschränkt: der Downloader läuft über
|
||||
/// yt-dlp am Server und kann mehr als YouTube. Was davon geht, entscheidet
|
||||
/// der Server, nicht diese Zeile.
|
||||
String? adresseAus(String? text) {
|
||||
if (text == null || text.isEmpty) return null;
|
||||
final treffer = _adresse.firstMatch(text);
|
||||
if (treffer == null) return null;
|
||||
// Satzzeichen am Ende gehören zum Satz, nicht zur Adresse.
|
||||
return treffer.group(0)!.replaceAll(RegExp(r'[.,;:!?)\]]+$'), '');
|
||||
}
|
||||
|
||||
final _adresse = RegExp(r'https?://\S+');
|
||||
|
||||
/// Nimmt entgegen, was andere Apps über „Teilen" an Melo schicken.
|
||||
///
|
||||
/// Eigene Brücke statt eines Zusatzpakets — das Projekt hat für MediaStore
|
||||
/// und Klangeffekte bereits eigene (siehe `MediaStoreBridge`).
|
||||
class GeteilteAdresse {
|
||||
const GeteilteAdresse({this.channel = const MethodChannel(kanalName)});
|
||||
|
||||
static const kanalName = 'de.baka.melo/geteilt';
|
||||
|
||||
final MethodChannel channel;
|
||||
|
||||
/// Die Adresse, mit der die App gerade geöffnet wurde, oder `null`.
|
||||
///
|
||||
/// Wird beim Abholen auf der Android-Seite gelöscht: sonst spränge die App
|
||||
/// bei jedem Wiedereintritt erneut in den Downloader.
|
||||
Future<String?> abholen() async {
|
||||
try {
|
||||
final text = await channel.invokeMethod<String>('hole');
|
||||
return adresseAus(text);
|
||||
} on PlatformException {
|
||||
return null;
|
||||
} on MissingPluginException {
|
||||
// Andere Plattformen kennen die Brücke nicht — dort gibt es kein
|
||||
// Teilen an Melo, und das ist kein Fehler.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
-10
@@ -93,28 +93,53 @@ class MeloTheme {
|
||||
/// Flächen sauberer als ein Schatten, den man ohnehin nicht sieht.
|
||||
static Border get hairline => Border.all(color: border, width: 1);
|
||||
|
||||
/// Die Stufenleiter der Schrift.
|
||||
///
|
||||
/// Melo bleibt bei der System-Schrift (auf Android Roboto): sie ist immer
|
||||
/// da, trägt jede Sprache und kostet nichts an App-Größe. Geschärft wird
|
||||
/// stattdessen, was ohne eigene Schrift geht — Größenabstände, Gewichte
|
||||
/// und vor allem die **Laufweite**: große Grade wirken mit den Standard-
|
||||
/// abständen auseinandergezogen, Großbuchstaben dagegen gedrängt.
|
||||
static TextTheme get _textTheme => const TextTheme(
|
||||
// Bildschirm-Überschrift ("Meine Musik", "Online").
|
||||
// Bildschirm-Überschrift ("Favoriten", "Download", "Suchen").
|
||||
// Etwas enger gesetzt, damit die Zeile als ein Wortbild steht.
|
||||
displaySmall: TextStyle(
|
||||
fontSize: 28, height: 1.15, fontWeight: FontWeight.w700, color: text1),
|
||||
fontSize: 26,
|
||||
height: 1.15,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.4,
|
||||
color: text1),
|
||||
// Titelleiste, Kopf eines Sheets.
|
||||
headlineSmall: TextStyle(
|
||||
fontSize: 22, height: 1.2, fontWeight: FontWeight.w700, color: text1),
|
||||
fontSize: 22,
|
||||
height: 1.2,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.3,
|
||||
color: text1),
|
||||
// Hervorgehobene Zeile ("Shuffle-Wiedergabe").
|
||||
titleMedium: TextStyle(
|
||||
fontSize: 17, height: 1.3, fontWeight: FontWeight.w600, color: text1),
|
||||
// Titel in einer Liste.
|
||||
bodyLarge: TextStyle(fontSize: 15, height: 1.4, color: text1),
|
||||
fontSize: 17,
|
||||
height: 1.3,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.1,
|
||||
color: text1),
|
||||
// Titel in einer Liste. Zeilenhöhe bleibt bei 1,4 — dass Fließtext
|
||||
// nicht enger gesetzt wird, hält `theme_kontrast_test.dart` als
|
||||
// Lesbarkeitsregel fest, und die ist älter als diese Politur.
|
||||
bodyLarge: TextStyle(
|
||||
fontSize: 15, height: 1.4, letterSpacing: 0, color: text1),
|
||||
// Untertitel in einer Liste.
|
||||
bodyMedium: TextStyle(fontSize: 13, height: 1.35, color: text2),
|
||||
bodyMedium: TextStyle(
|
||||
fontSize: 13, height: 1.35, letterSpacing: 0, color: text2),
|
||||
// Beiwerk: Anzahl, Dauer.
|
||||
bodySmall: TextStyle(fontSize: 12, height: 1.3, color: text3),
|
||||
// Abschnitts-Beschriftung, Knopftext.
|
||||
// Abschnitts-Beschriftung, Knopftext. Wird in Großbuchstaben
|
||||
// gesetzt — und die brauchen mehr Luft, nicht weniger.
|
||||
labelLarge: TextStyle(
|
||||
fontSize: 12,
|
||||
height: 1.2,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.4,
|
||||
letterSpacing: 0.6,
|
||||
color: text3),
|
||||
);
|
||||
|
||||
@@ -138,8 +163,14 @@ class MeloTheme {
|
||||
backgroundColor: black,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
centerTitle: false,
|
||||
// Dieselbe Stufe wie eine Überschrift — sonst hätte derselbe Rang
|
||||
// zwei verschiedene Schriftbilder.
|
||||
titleTextStyle: TextStyle(
|
||||
fontSize: 22, height: 1.2, fontWeight: FontWeight.w700, color: text1),
|
||||
fontSize: 22,
|
||||
height: 1.2,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.3,
|
||||
color: text1),
|
||||
),
|
||||
listTileTheme: const ListTileThemeData(
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: MeloSpace.md),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Überschrift eines Bildschirms ("Favoriten", "Download", "Suchen").
|
||||
///
|
||||
/// Nimmt die Stufe aus dem Theme ([TextTheme.displaySmall]) statt eigene
|
||||
/// Werte zu setzen. Vorher stand in drei Bildschirmen dieselbe von Hand
|
||||
/// gesetzte Größe, während die Stufe im Theme ungenutzt danebenlag — eine
|
||||
/// Änderung dort wäre nirgends angekommen.
|
||||
class Ueberschrift extends StatelessWidget {
|
||||
const Ueberschrift(this.text, {super.key});
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(text, style: Theme.of(context).textTheme.displaySmall),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user