v2.48 — Shuffle-Modus

## PlayerService
- PlayerService ist jetzt ChangeNotifier (UI hängt per addListener/ListenableBuilder an)
- zufallsmodus Getter + setZufallsmodus(bool)
- Zufallsreihenfolge: alle Queue-Indizes außer dem aktuellen Song, gemischt
- naechstes(): bei Shuffle nächster Song aus Zufallsreihenfolge, ohne Wiederholung bis alle durch
- vorheriges(): geht in der Zufallsreihenfolge zurück
- spiele(): synchronisiert Shuffle-Position (auch bei manueller Auswahl)

## MiniPlayer
- Shuffle-Icon (rot wenn aktiv, Tipp schaltet um)

Keine neuen Packages, flutter analyze 0 Issues, Tests 4/4
This commit is contained in:
Dustin
2026-08-04 16:23:55 +02:00
parent 319b765d08
commit bb8ee56894
2 changed files with 108 additions and 8 deletions
+87 -6
View File
@@ -1,12 +1,13 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
import '../models/song.dart'; import '../models/song.dart';
import '../database/db_helper.dart'; import '../database/db_helper.dart';
import 'melo_logger.dart'; import 'melo_logger.dart';
class PlayerService { class PlayerService extends ChangeNotifier {
static final PlayerService _instanz = PlayerService._(); static final PlayerService _instanz = PlayerService._();
factory PlayerService() => _instanz; factory PlayerService() => _instanz;
PlayerService._(); PlayerService._();
@@ -16,6 +17,16 @@ class PlayerService {
int _aktuellerIndex = -1; int _aktuellerIndex = -1;
StreamSubscription<PlayerState>? _autoNextSub; StreamSubscription<PlayerState>? _autoNextSub;
// ─── Zufallsmodus (Shuffle) ───
bool _zufallsmodus = false;
/// Zufällige Reihenfolge der Warteschlangen-Indizes (ohne den aktuellen Song).
/// Der aktuelle Song erscheint erst wieder, wenn er manuell gewählt wird.
final List<int> _shuffleReihenfolge = [];
/// Position des zuletzt gespielten Songs in [_shuffleReihenfolge] (-1 = noch nichts).
int _shufflePos = -1;
AudioPlayer get _p { AudioPlayer get _p {
if (_player == null) { if (_player == null) {
_player = AudioPlayer(); _player = AudioPlayer();
@@ -47,12 +58,59 @@ class PlayerService {
final StreamController<Song?> _songWechsel = StreamController.broadcast(); final StreamController<Song?> _songWechsel = StreamController.broadcast();
Stream<Song?> get onSongWechsel => _songWechsel.stream; Stream<Song?> get onSongWechsel => _songWechsel.stream;
// ─── Zufallsmodus (Shuffle) ───
bool get zufallsmodus => _zufallsmodus;
void setZufallsmodus(bool wert) {
if (_zufallsmodus == wert) return;
_zufallsmodus = wert;
if (wert) _baueShuffleReihenfolge();
notifyListeners();
}
/// Baut die Zufallsreihenfolge neu: alle Indizes außer dem aktuellen Song, gemischt.
void _baueShuffleReihenfolge() {
_shuffleReihenfolge.clear();
_shufflePos = -1;
if (_warteschlange.isEmpty) return;
if (_warteschlange.length == 1) {
_shuffleReihenfolge.add(0);
return;
}
final indices = List<int>.generate(_warteschlange.length, (i) => i);
if (_aktuellerIndex >= 0 && _aktuellerIndex < _warteschlange.length) {
indices.remove(_aktuellerIndex);
}
indices.shuffle(Random());
_shuffleReihenfolge.addAll(indices);
}
/// Nächster Index in der Zufallsreihenfolge (null = alle durchgespielt).
int? _naechsterShuffleIndex() {
_shufflePos++;
if (_shufflePos < 0 || _shufflePos >= _shuffleReihenfolge.length) {
return null;
}
final idx = _shuffleReihenfolge[_shufflePos];
return (idx >= 0 && idx < _warteschlange.length) ? idx : null;
}
Future<void> spiele(Song song, {int position = 0}) async { Future<void> spiele(Song song, {int position = 0}) async {
_aktuellerIndex = _warteschlange.indexWhere((s) => s.id == song.id); _aktuellerIndex = _warteschlange.indexWhere((s) => s.id == song.id);
if (_aktuellerIndex < 0) { if (_aktuellerIndex < 0) {
_warteschlange.add(song); _warteschlange.add(song);
_aktuellerIndex = _warteschlange.length - 1; _aktuellerIndex = _warteschlange.length - 1;
} }
// Shuffle-Position synchron halten (Song kann über Queue-UI/Manuell gewählt sein)
if (_zufallsmodus) {
final pos = _shuffleReihenfolge.indexOf(_aktuellerIndex);
if (pos < 0) {
_baueShuffleReihenfolge();
} else {
_shufflePos = pos;
}
}
try { try {
if (song.streamUrl != null && song.streamUrl!.isNotEmpty) { if (song.streamUrl != null && song.streamUrl!.isNotEmpty) {
// Streaming vom Server // Streaming vom Server
@@ -67,6 +125,7 @@ class PlayerService {
if (position > 0) await _p.seek(Duration(seconds: position)); if (position > 0) await _p.seek(Duration(seconds: position));
await _p.play(); await _p.play();
_songWechsel.add(song); _songWechsel.add(song);
notifyListeners();
// Abspiel-Historie für den Jahres-Recap registrieren // Abspiel-Historie für den Jahres-Recap registrieren
if (song.id != null) { if (song.id != null) {
try { try {
@@ -117,15 +176,31 @@ class PlayerService {
final pos = _p.position; final pos = _p.position;
if (pos.inSeconds > 5) { if (pos.inSeconds > 5) {
await _p.seek(Duration.zero); await _p.seek(Duration.zero);
} else { return;
final neuerIndex = _aktuellerIndex - 1; }
if (neuerIndex >= 0) { if (_zufallsmodus && _shuffleReihenfolge.isNotEmpty) {
await spiele(_warteschlange[neuerIndex]); _shufflePos--;
if (_shufflePos < 0) _shufflePos = 0;
final idx = _shuffleReihenfolge[_shufflePos];
if (idx >= 0 && idx < _warteschlange.length) {
await spiele(_warteschlange[idx]);
} }
return;
}
final neuerIndex = _aktuellerIndex - 1;
if (neuerIndex >= 0) {
await spiele(_warteschlange[neuerIndex]);
} }
} }
Future<void> naechstes() async { Future<void> naechstes() async {
if (_player == null || aktuellerSong == null) return;
if (_zufallsmodus) {
final idx = _naechsterShuffleIndex();
if (idx == null) return;
await spiele(_warteschlange[idx]);
return;
}
final neuerIndex = _aktuellerIndex + 1; final neuerIndex = _aktuellerIndex + 1;
if (neuerIndex < _warteschlange.length) { if (neuerIndex < _warteschlange.length) {
await spiele(_warteschlange[neuerIndex]); await spiele(_warteschlange[neuerIndex]);
@@ -135,14 +210,20 @@ class PlayerService {
void setWarteschlange(List<Song> songs, {int startIndex = 0}) { void setWarteschlange(List<Song> songs, {int startIndex = 0}) {
_warteschlange.clear(); _warteschlange.clear();
_warteschlange.addAll(songs); _warteschlange.addAll(songs);
_aktuellerIndex = startIndex; _aktuellerIndex = _warteschlange.isEmpty
? -1
: startIndex.clamp(0, _warteschlange.length - 1);
_baueShuffleReihenfolge();
notifyListeners();
} }
@override
void dispose() { void dispose() {
_autoNextSub?.cancel(); _autoNextSub?.cancel();
_player?.dispose(); _player?.dispose();
_player = null; _player = null;
_songWechsel.close(); _songWechsel.close();
super.dispose();
} }
/// Markiert den aktuellen Song in der DB als korrupt /// Markiert den aktuellen Song in der DB als korrupt
+21 -2
View File
@@ -43,10 +43,17 @@ class _MiniPlayerState extends State<MiniPlayer> {
_songSub = _player.onSongWechsel.listen((_) { _songSub = _player.onSongWechsel.listen((_) {
if (mounted) setState(() {}); if (mounted) setState(() {});
}); });
// Shuffle-/Repeat-/Sleep-Zustand live halten
_player.addListener(_onPlayerChanged);
}
void _onPlayerChanged() {
if (mounted) setState(() {});
} }
@override @override
void dispose() { void dispose() {
_player.removeListener(_onPlayerChanged);
_posSub?.cancel(); _posSub?.cancel();
_stateSub?.cancel(); _stateSub?.cancel();
_songSub?.cancel(); _songSub?.cancel();
@@ -98,6 +105,8 @@ class _MiniPlayerState extends State<MiniPlayer> {
], ],
), ),
), ),
// Shuffle
_shuffleBtn(),
// Steuerung // Steuerung
_btn(Icons.skip_previous, _player.vorheriges), _btn(Icons.skip_previous, _player.vorheriges),
_playBtn(), _playBtn(),
@@ -123,7 +132,17 @@ class _MiniPlayerState extends State<MiniPlayer> {
); );
} }
Widget _btn(IconData icon, VoidCallback onTap) { /// Shuffle-Icon — rot wenn aktiv
Widget _shuffleBtn() {
final aktiv = _player.zufallsmodus;
return _btn(
Icons.shuffle,
() => _player.setZufallsmodus(!aktiv),
farbe: aktiv ? MeloTheme.rot : Colors.white,
);
}
Widget _btn(IconData icon, VoidCallback onTap, {Color farbe = Colors.white}) {
return Material( return Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
@@ -132,7 +151,7 @@ class _MiniPlayerState extends State<MiniPlayer> {
child: Container( child: Container(
width: 32, height: 32, width: 32, height: 32,
alignment: Alignment.center, alignment: Alignment.center,
child: Icon(icon, size: 18, color: Colors.white), child: Icon(icon, size: 18, color: farbe),
), ),
), ),
); );