Initial commit
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../models/song.dart';
|
||||
import '../database/db_helper.dart';
|
||||
|
||||
class DownloadService {
|
||||
static final DownloadService _instanz = DownloadService._();
|
||||
factory DownloadService() => _instanz;
|
||||
DownloadService._();
|
||||
|
||||
final YoutubeExplode _yt = YoutubeExplode();
|
||||
final DbHelper _db = DbHelper();
|
||||
|
||||
bool _ladt = false;
|
||||
double _fortschritt = 0;
|
||||
String? _aktuellerTitel;
|
||||
String? _fehler;
|
||||
|
||||
bool get ladt => _ladt;
|
||||
double get fortschritt => _fortschritt;
|
||||
String? get aktuellerTitel => _aktuellerTitel;
|
||||
String? get fehler => _fehler;
|
||||
|
||||
void _resetStatus() {
|
||||
_ladt = true;
|
||||
_fortschritt = 0;
|
||||
_aktuellerTitel = null;
|
||||
_fehler = null;
|
||||
}
|
||||
|
||||
Future<Song?> downloadVonUrl(String url) async {
|
||||
_resetStatus();
|
||||
|
||||
try {
|
||||
// URL-Validierung
|
||||
if (!url.contains('youtube.com') && !url.contains('youtu.be')) {
|
||||
_fehler = 'Keine gültige YouTube-URL';
|
||||
_ladt = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
String titel = '';
|
||||
String kuenstler = '';
|
||||
int dauer = 0;
|
||||
|
||||
// Video-Info mit Timeout
|
||||
_aktuellerTitel = 'YouTube wird kontaktiert...';
|
||||
try {
|
||||
final video = await _yt.videos.get(url).timeout(const Duration(seconds: 15));
|
||||
titel = video.title;
|
||||
kuenstler = video.author;
|
||||
dauer = video.duration?.inSeconds ?? 0;
|
||||
_aktuellerTitel = 'Video gefunden: $titel';
|
||||
} catch (e) {
|
||||
_fehler = 'Timeout/Fehler bei Video-Info: ${e.toString()}';
|
||||
debugPrint('Video-Info Fehler (vollständig): $e');
|
||||
_ladt = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Manifest abrufen
|
||||
_aktuellerTitel = 'Audio-Stream wird ermittelt... (2/4)';
|
||||
AudioStreamInfo? audio;
|
||||
try {
|
||||
final manifest = await _yt.videos.streamsClient
|
||||
.getManifest(url).timeout(const Duration(seconds: 15));
|
||||
final streams = manifest.audioOnly.toList();
|
||||
if (streams.isEmpty) {
|
||||
_fehler = 'Kein Audio-Stream verfügbar';
|
||||
_ladt = false;
|
||||
return null;
|
||||
}
|
||||
audio = streams.reduce((a, b) => a.bitrate.bitsPerSecond > b.bitrate.bitsPerSecond ? a : b);
|
||||
} catch (e) {
|
||||
_fehler = 'Stream-Fehler: ${e.toString()}';
|
||||
debugPrint('Stream Manifest Fehler (vollständig): $e');
|
||||
_ladt = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Zielpfad
|
||||
_aktuellerTitel = 'Speicher wird vorbereitet... (3/4)';
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final musikDir = Directory('${dir.path}/music');
|
||||
if (!await musikDir.exists()) await musikDir.create(recursive: true);
|
||||
final safeName = titel.replaceAll(RegExp(r'[^\w\s-]'), '').trim();
|
||||
final dateiName = '${safeName.isEmpty ? "song" : safeName}.mp4';
|
||||
final dateiPfad = '${musikDir.path}/$dateiName';
|
||||
|
||||
// Download
|
||||
_aktuellerTitel = 'Lade herunter... (4/4)';
|
||||
try {
|
||||
final fileStream = _yt.videos.streamsClient.get(audio);
|
||||
final file = File(dateiPfad);
|
||||
final sink = file.openWrite();
|
||||
int downloaded = 0;
|
||||
final total = audio.size.totalBytes;
|
||||
await for (final chunk in fileStream) {
|
||||
sink.add(chunk);
|
||||
downloaded += chunk.length;
|
||||
_fortschritt = downloaded / total;
|
||||
}
|
||||
await sink.flush();
|
||||
await sink.close();
|
||||
} catch (e) {
|
||||
_fehler = 'Download-Fehler: ${e.toString()}';
|
||||
debugPrint('Download Stream Fehler (vollständig): $e');
|
||||
_ladt = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Song speichern
|
||||
final song = Song(
|
||||
titel: titel,
|
||||
kuenstler: kuenstler,
|
||||
album: 'YouTube',
|
||||
dauerSekunden: dauer,
|
||||
dateiPfad: dateiPfad,
|
||||
groesseBytes: await File(dateiPfad).length(),
|
||||
istHeruntergeladen: true,
|
||||
downloadQuelle: 'youtube',
|
||||
);
|
||||
await _db.songEinfuegen(song);
|
||||
|
||||
_ladt = false;
|
||||
_aktuellerTitel = '✅ ${song.titel} heruntergeladen';
|
||||
return song;
|
||||
} catch (e) {
|
||||
_fehler = 'Fehler: ${e.toString()}';
|
||||
debugPrint('Download allgemeiner Fehler (vollständig): $e');
|
||||
_ladt = false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_yt.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import '../database/db_helper.dart';
|
||||
import '../models/song.dart';
|
||||
|
||||
class FavoritenService {
|
||||
static final FavoritenService _instanz = FavoritenService._();
|
||||
factory FavoritenService() => _instanz;
|
||||
FavoritenService._();
|
||||
|
||||
final DbHelper _db = DbHelper();
|
||||
int? _favoritenPlaylistId;
|
||||
|
||||
int? get favoritenId => _favoritenPlaylistId;
|
||||
|
||||
Future<void> init() async {
|
||||
final playlists = await _db.allePlaylists();
|
||||
final vorhanden = playlists.where((p) => p['name'] == '⭐ Favoriten').toList();
|
||||
if (vorhanden.isNotEmpty) {
|
||||
_favoritenPlaylistId = vorhanden.first['id'] as int;
|
||||
} else {
|
||||
_favoritenPlaylistId = await _db.playlistErstellen('⭐ Favoriten');
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> istFavorit(int songId) async {
|
||||
if (_favoritenPlaylistId == null) return false;
|
||||
final songs = await _db.songsDerPlaylist(_favoritenPlaylistId!);
|
||||
return songs.any((s) => s.id == songId);
|
||||
}
|
||||
|
||||
Future<void> umschalten(int songId) async {
|
||||
if (_favoritenPlaylistId == null) return;
|
||||
if (await istFavorit(songId)) {
|
||||
await _db.songAusPlaylistEntfernen(_favoritenPlaylistId!, songId);
|
||||
} else {
|
||||
final songs = await _db.songsDerPlaylist(_favoritenPlaylistId!);
|
||||
await _db.songZurPlaylist(_favoritenPlaylistId!, songId, songs.length);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Song>> alleFavoriten() async {
|
||||
if (_favoritenPlaylistId == null) return [];
|
||||
return _db.songsDerPlaylist(_favoritenPlaylistId!);
|
||||
}
|
||||
|
||||
Future<Set<int>> favoritenIds() async {
|
||||
if (_favoritenPlaylistId == null) return {};
|
||||
final d = await _db.db;
|
||||
final rows = await d.rawQuery(
|
||||
'SELECT song_id FROM playlist_songs WHERE playlist_id = ?',
|
||||
[_favoritenPlaylistId],
|
||||
);
|
||||
return rows.map((r) => r['song_id'] as int).toSet();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import '../models/song.dart';
|
||||
import '../database/db_helper.dart';
|
||||
|
||||
class MusikScanner {
|
||||
static final MusikScanner _instanz = MusikScanner._();
|
||||
factory MusikScanner() => _instanz;
|
||||
MusikScanner._();
|
||||
|
||||
final DbHelper _db = DbHelper();
|
||||
int _neueSongs = 0;
|
||||
|
||||
int get anzahlNeueSongs => _neueSongs;
|
||||
|
||||
Future<bool> frageSpeicherZugriff() async {
|
||||
final status = await Permission.audio.request();
|
||||
return status.isGranted;
|
||||
}
|
||||
|
||||
Future<List<Song>> scanneMusikOrdner() async {
|
||||
_neueSongs = 0;
|
||||
final gefunden = <Song>[];
|
||||
final pfade = await _sammleMusikPfade();
|
||||
|
||||
for (final pfad in pfade) {
|
||||
try {
|
||||
final file = File(pfad);
|
||||
if (!await file.exists()) continue;
|
||||
final stat = await file.stat();
|
||||
gefunden.add(Song(
|
||||
titel: _dateiNameOhneEndung(pfad),
|
||||
kuenstler: 'Unbekannt',
|
||||
album: '',
|
||||
dauerSekunden: await _ermittleDauer(pfad),
|
||||
dateiPfad: pfad,
|
||||
coverPfad: null,
|
||||
groesseBytes: stat.size,
|
||||
istHeruntergeladen: true,
|
||||
downloadQuelle: 'local',
|
||||
));
|
||||
} catch (_) {
|
||||
// Datei nicht lesbar → überspringen
|
||||
}
|
||||
}
|
||||
|
||||
// In DB speichern
|
||||
final vorhandene = await _db.alleSongs();
|
||||
final vorhandenePfade = vorhandene.map((s) => s.dateiPfad).toSet();
|
||||
|
||||
final neue = gefunden.where((s) => !vorhandenePfade.contains(s.dateiPfad)).toList();
|
||||
if (neue.isNotEmpty) {
|
||||
await _db.songsEinfuegen(neue);
|
||||
_neueSongs = neue.length;
|
||||
}
|
||||
|
||||
return gefunden;
|
||||
}
|
||||
|
||||
Future<List<String>> _sammleMusikPfade() async {
|
||||
final pfade = <String>{};
|
||||
|
||||
// Typische Musik-Ordner auf Android
|
||||
final ordner = [
|
||||
'/storage/emulated/0/Music',
|
||||
'/storage/emulated/0/Download',
|
||||
'/storage/emulated/0/Musik',
|
||||
'/storage/emulated/0/Downloads',
|
||||
'/sdcard/Music',
|
||||
'/sdcard/Download',
|
||||
'/sdcard/Musik',
|
||||
];
|
||||
|
||||
// Externe SD-Karte (falls vorhanden)
|
||||
try {
|
||||
final extern = await getExternalStorageDirectory();
|
||||
if (extern != null) {
|
||||
ordner.add(extern.path);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Android Media Store (bessere Methode)
|
||||
try {
|
||||
final pfadeVonMediaStore = await _scanneViaMediaStore();
|
||||
pfade.addAll(pfadeVonMediaStore);
|
||||
} catch (_) {}
|
||||
|
||||
// Fallback: Dateisystem durchsuchen
|
||||
for (final ord in ordner) {
|
||||
try {
|
||||
final dir = Directory(ord);
|
||||
if (await dir.exists()) {
|
||||
await _durchsucheOrdner(dir, pfade);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return pfade.toList();
|
||||
}
|
||||
|
||||
Future<List<String>> _scanneViaMediaStore() async {
|
||||
// Nutzt Android's MediaStore Query
|
||||
// Wird über Method Channel in native Android implementiert
|
||||
// Für v1: Fallback auf Dateisystem-Suche
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<void> _durchsucheOrdner(Directory dir, Set<String> pfade, {int tiefe = 0}) async {
|
||||
if (tiefe > 4) return;
|
||||
try {
|
||||
await for (final entity in dir.list(followLinks: false)) {
|
||||
if (entity is File) {
|
||||
final ext = entity.path.toLowerCase();
|
||||
if (ext.endsWith('.mp3') || ext.endsWith('.m4a') ||
|
||||
ext.endsWith('.flac') || ext.endsWith('.wav') ||
|
||||
ext.endsWith('.aac') || ext.endsWith('.ogg')) {
|
||||
pfade.add(entity.path);
|
||||
}
|
||||
} else if (entity is Directory) {
|
||||
await _durchsucheOrdner(entity, pfade, tiefe: tiefe + 1);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<int> _ermittleDauer(String pfad) async {
|
||||
try {
|
||||
final player = AudioPlayer();
|
||||
await player.setFilePath(pfad);
|
||||
final dauer = player.duration;
|
||||
await player.dispose();
|
||||
return dauer?.inSeconds ?? 0;
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
String _dateiNameOhneEndung(String pfad) {
|
||||
final name = pfad.split('/').last;
|
||||
final dot = name.lastIndexOf('.');
|
||||
return dot > 0 ? name.substring(0, dot) : name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import '../models/song.dart';
|
||||
|
||||
class PlayerService {
|
||||
static final PlayerService _instanz = PlayerService._();
|
||||
factory PlayerService() => _instanz;
|
||||
PlayerService._();
|
||||
|
||||
AudioPlayer? _player;
|
||||
final List<Song> _warteschlange = [];
|
||||
int _aktuellerIndex = -1;
|
||||
StreamSubscription<PlayerState>? _autoNextSub;
|
||||
|
||||
AudioPlayer get _p {
|
||||
if (_player == null) {
|
||||
_player = AudioPlayer();
|
||||
_autoNextSub = _player!.playerStateStream.listen((state) {
|
||||
if (state.processingState == ProcessingState.completed) {
|
||||
naechstes();
|
||||
}
|
||||
});
|
||||
}
|
||||
return _player!;
|
||||
}
|
||||
|
||||
Song? get aktuellerSong => _aktuellerIndex >= 0 && _aktuellerIndex < _warteschlange.length
|
||||
? _warteschlange[_aktuellerIndex] : null;
|
||||
bool get spieltAb => _player?.playing ?? false;
|
||||
Duration get position => _player?.position ?? Duration.zero;
|
||||
Duration get dauer => _player?.duration ?? Duration.zero;
|
||||
Stream<Duration> get positionStream => _p.positionStream;
|
||||
Stream<PlayerState> get stateStream => _p.playerStateStream;
|
||||
|
||||
final StreamController<Song?> _songWechsel = StreamController.broadcast();
|
||||
Stream<Song?> get onSongWechsel => _songWechsel.stream;
|
||||
|
||||
Future<void> spiele(Song song, {int position = 0}) async {
|
||||
_aktuellerIndex = _warteschlange.indexWhere((s) => s.id == song.id);
|
||||
if (_aktuellerIndex < 0) {
|
||||
_warteschlange.add(song);
|
||||
_aktuellerIndex = _warteschlange.length - 1;
|
||||
}
|
||||
try {
|
||||
await _p.setFilePath(song.dateiPfad);
|
||||
if (position > 0) await _p.seek(Duration(seconds: position));
|
||||
await _p.play();
|
||||
_songWechsel.add(song);
|
||||
} catch (e) {
|
||||
debugPrint('Fehler beim Abspielen: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> playPause() async {
|
||||
if (_player == null) return;
|
||||
if (_p.playing) {
|
||||
await _p.pause();
|
||||
} else if (aktuellerSong != null) {
|
||||
await _p.play();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> vorheriges() async {
|
||||
if (_player == null || aktuellerSong == null) return;
|
||||
final pos = _p.position;
|
||||
if (pos.inSeconds > 5) {
|
||||
await _p.seek(Duration.zero);
|
||||
} else {
|
||||
final neuerIndex = _aktuellerIndex - 1;
|
||||
if (neuerIndex >= 0) {
|
||||
await spiele(_warteschlange[neuerIndex]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> naechstes() async {
|
||||
final neuerIndex = _aktuellerIndex + 1;
|
||||
if (neuerIndex < _warteschlange.length) {
|
||||
await spiele(_warteschlange[neuerIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
void setWarteschlange(List<Song> songs, {int startIndex = 0}) {
|
||||
_warteschlange.clear();
|
||||
_warteschlange.addAll(songs);
|
||||
_aktuellerIndex = startIndex;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_autoNextSub?.cancel();
|
||||
_player?.dispose();
|
||||
_songWechsel.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user