Initial commit
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../utils/farb_theme.dart';
|
||||
|
||||
class MeloHeader extends StatelessWidget {
|
||||
final VoidCallback onDownload;
|
||||
final VoidCallback onSearch;
|
||||
|
||||
const MeloHeader({super.key, required this.onDownload, required this.onSearch});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ShaderMask(
|
||||
shaderCallback: (bounds) => const LinearGradient(
|
||||
colors: [Colors.white, MeloTheme.rot],
|
||||
).createShader(bounds),
|
||||
child: const Text('Melo', style: TextStyle(
|
||||
fontSize: 28, fontWeight: FontWeight.w700, color: Colors.white)),
|
||||
),
|
||||
const Text('Deine Musik. Deine Art.', style: TextStyle(fontSize: 12, color: MeloTheme.textSekundaer)),
|
||||
],
|
||||
),
|
||||
Row(children: [
|
||||
_btn(Icons.download, onDownload),
|
||||
const SizedBox(width: 8),
|
||||
_btn(Icons.search, onSearch),
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _btn(IconData icon, VoidCallback onTap) {
|
||||
return Container(
|
||||
width: 40, height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: MeloTheme.dunkel1,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(icon, size: 18, color: MeloTheme.rot),
|
||||
onPressed: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/song.dart';
|
||||
import '../database/db_helper.dart';
|
||||
import '../utils/farb_theme.dart';
|
||||
|
||||
class MetadatenDialog extends StatefulWidget {
|
||||
final Song song;
|
||||
const MetadatenDialog({super.key, required this.song});
|
||||
|
||||
@override
|
||||
State<MetadatenDialog> createState() => _MetadatenDialogState();
|
||||
}
|
||||
|
||||
class _MetadatenDialogState extends State<MetadatenDialog> {
|
||||
late TextEditingController _titelCtrl;
|
||||
late TextEditingController _kuenstlerCtrl;
|
||||
late TextEditingController _albumCtrl;
|
||||
final DbHelper _db = DbHelper();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_titelCtrl = TextEditingController(text: widget.song.titel);
|
||||
_kuenstlerCtrl = TextEditingController(text: widget.song.kuenstler);
|
||||
_albumCtrl = TextEditingController(text: widget.song.album);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titelCtrl.dispose();
|
||||
_kuenstlerCtrl.dispose();
|
||||
_albumCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
backgroundColor: MeloTheme.dunkel1,
|
||||
title: const Text('✏️ Metadaten', style: TextStyle(color: Colors.white, fontSize: 18)),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_field('Titel', _titelCtrl),
|
||||
const SizedBox(height: 12),
|
||||
_field('Künstler', _kuenstlerCtrl),
|
||||
const SizedBox(height: 12),
|
||||
_field('Album', _albumCtrl),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Abbrechen', style: TextStyle(color: Colors.white54)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _speichern(),
|
||||
child: const Text('Speichern', style: TextStyle(color: MeloTheme.rot)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _field(String label, TextEditingController ctrl) {
|
||||
return TextField(
|
||||
controller: ctrl,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
labelStyle: const TextStyle(color: Colors.grey),
|
||||
border: const OutlineInputBorder(),
|
||||
focusedBorder: const OutlineInputBorder(
|
||||
borderSide: BorderSide(color: MeloTheme.rot),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _speichern() async {
|
||||
final id = widget.song.id;
|
||||
if (id == null) {
|
||||
if (mounted) Navigator.pop(context, false);
|
||||
return;
|
||||
}
|
||||
final titel = _titelCtrl.text.trim();
|
||||
final kuenstler = _kuenstlerCtrl.text.trim();
|
||||
final album = _albumCtrl.text.trim();
|
||||
if (titel.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Titel darf nicht leer sein')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await _db.metadatenAktualisieren(
|
||||
id,
|
||||
titel: titel,
|
||||
kuenstler: kuenstler,
|
||||
album: album,
|
||||
);
|
||||
if (mounted) Navigator.pop(context, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import '../services/player_service.dart';
|
||||
import '../utils/farb_theme.dart';
|
||||
|
||||
class MiniPlayer extends StatefulWidget {
|
||||
const MiniPlayer({super.key});
|
||||
|
||||
@override
|
||||
State<MiniPlayer> createState() => _MiniPlayerState();
|
||||
}
|
||||
|
||||
class _MiniPlayerState extends State<MiniPlayer> {
|
||||
final PlayerService _player = PlayerService();
|
||||
Duration _position = Duration.zero;
|
||||
Duration _dauer = Duration.zero;
|
||||
bool _spielt = false;
|
||||
StreamSubscription<Duration>? _posSub;
|
||||
StreamSubscription<PlayerState>? _stateSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_posSub = _player.positionStream.listen((pos) {
|
||||
if (mounted) {
|
||||
setState(() => _position = pos);
|
||||
}
|
||||
});
|
||||
_stateSub = _player.stateStream.listen((state) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_spielt = state.playing;
|
||||
_dauer = state.processingState == ProcessingState.ready
|
||||
? (_player.dauer) : Duration.zero;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_posSub?.cancel();
|
||||
_stateSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final song = _player.aktuellerSong;
|
||||
if (song == null) return const SizedBox.shrink();
|
||||
|
||||
final progress = _dauer.inSeconds > 0
|
||||
? _position.inSeconds / _dauer.inSeconds : 0.0;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A0A0A),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFF2A1515)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
// Cover
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Container(
|
||||
width: 36, height: 36,
|
||||
color: MeloTheme.rot,
|
||||
child: const Center(child: Text('♪', style: TextStyle(fontSize: 16))),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Titel + Künstler
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(song.titel, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white)),
|
||||
Text(song.kuenstler, style: const TextStyle(fontSize: 11, color: MeloTheme.textSekundaer)),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Steuerung
|
||||
_btn(Icons.skip_previous, _player.vorheriges),
|
||||
_playBtn(),
|
||||
_btn(Icons.skip_next, _player.naechstes),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Fortschritt
|
||||
if (_dauer.inSeconds > 0) Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
backgroundColor: const Color(0xFF2A2A2A),
|
||||
valueColor: const AlwaysStoppedAnimation(MeloTheme.rot),
|
||||
minHeight: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _btn(IconData icon, VoidCallback onTap) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 32, height: 32,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(icon, size: 18, color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _playBtn() {
|
||||
return Material(
|
||||
color: MeloTheme.rot,
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
onTap: _player.playPause,
|
||||
child: Container(
|
||||
width: 36, height: 36,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(_spielt ? Icons.pause : Icons.play_arrow, size: 20, color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/song.dart';
|
||||
import '../utils/farb_theme.dart';
|
||||
import 'metadaten_dialog.dart';
|
||||
|
||||
class SongTile extends StatelessWidget {
|
||||
final Song song;
|
||||
final bool istFavorit;
|
||||
final ValueChanged<Song> onFavoriteToggle;
|
||||
final ValueChanged<Song> onPlay;
|
||||
final VoidCallback onMetadataChanged;
|
||||
|
||||
const SongTile({
|
||||
super.key,
|
||||
required this.song,
|
||||
required this.istFavorit,
|
||||
required this.onFavoriteToggle,
|
||||
required this.onPlay,
|
||||
required this.onMetadataChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hatDatei = song.dateiPfad.isNotEmpty;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 2),
|
||||
leading: Container(
|
||||
width: 44, height: 44,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
gradient: const LinearGradient(colors: [Color(0xFF1A0000), Color(0xFF660000)]),
|
||||
),
|
||||
child: const Center(child: Text('♪', style: TextStyle(fontSize: 18, color: Colors.white54))),
|
||||
),
|
||||
title: Text(song.titel, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Colors.white)),
|
||||
subtitle: Text(song.kuenstler, style: const TextStyle(fontSize: 12, color: MeloTheme.textSekundaer)),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
onTap: () async {
|
||||
final geaendert = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => MetadatenDialog(song: song),
|
||||
);
|
||||
if (geaendert == true) onMetadataChanged();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Icon(Icons.edit, size: 14, color: MeloTheme.textSekundaer),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
onTap: () => onFavoriteToggle(song),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Icon(
|
||||
istFavorit ? Icons.favorite : Icons.favorite_border,
|
||||
size: 16, color: MeloTheme.rot,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(song.dauerFormatiert, style: const TextStyle(fontSize: 12, color: MeloTheme.textSekundaer)),
|
||||
],
|
||||
),
|
||||
onTap: hatDatei ? () => onPlay(song) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../utils/farb_theme.dart';
|
||||
|
||||
class StatistikCard extends StatelessWidget {
|
||||
final int anzahlSongs;
|
||||
final String gesamtMB;
|
||||
final int gesamtMin;
|
||||
final int anzahlFavoriten;
|
||||
|
||||
const StatistikCard({
|
||||
super.key,
|
||||
required this.anzahlSongs,
|
||||
required this.gesamtMB,
|
||||
required this.gesamtMin,
|
||||
required this.anzahlFavoriten,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A0A0A),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: const Color(0xFF2A1515)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_item('$anzahlSongs', 'Lieder'),
|
||||
_item(gesamtMB, 'MB'),
|
||||
_item('$gesamtMin', 'Min'),
|
||||
_item('$anzahlFavoriten', 'Favoriten'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _item(String zahl, String label) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(zahl, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: MeloTheme.rot)),
|
||||
Text(label, style: const TextStyle(fontSize: 11, color: MeloTheme.textSekundaer)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../utils/farb_theme.dart';
|
||||
|
||||
class TagLeiste extends StatelessWidget {
|
||||
final List<Map<String, String>> tags;
|
||||
final String aktiverTag;
|
||||
final ValueChanged<String> onTagSelected;
|
||||
|
||||
const TagLeiste({
|
||||
super.key,
|
||||
required this.tags,
|
||||
required this.aktiverTag,
|
||||
required this.onTagSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('🏷️ Tags', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white)),
|
||||
Text('+ Neu', style: TextStyle(fontSize: 12, color: MeloTheme.rot, fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
children: tags.map((tag) {
|
||||
final aktiv = aktiverTag == tag['name'];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: FilterChip(
|
||||
label: Text('${tag['icon']} ${tag['name']}',
|
||||
style: TextStyle(fontSize: 13, color: aktiv ? Colors.white : MeloTheme.textSekundaer)),
|
||||
selected: aktiv,
|
||||
onSelected: (_) => onTagSelected(tag['name']!),
|
||||
selectedColor: MeloTheme.rot,
|
||||
backgroundColor: MeloTheme.dunkel1,
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user