diff --git a/android/app/build.gradle b/.github/android/app/build.gradle similarity index 100% rename from android/app/build.gradle rename to .github/android/app/build.gradle diff --git a/.github/android/app/gradle.properties b/.github/android/app/gradle.properties new file mode 100644 index 0000000..c9e8262 --- /dev/null +++ b/.github/android/app/gradle.properties @@ -0,0 +1,5 @@ +# Kept intentionally minimal. +# Flutter template-compatible properties. +android.useAndroidX=true +android.enableJetifier=true + diff --git a/android/app/proguard-rules.pro b/.github/android/app/proguard-rules.pro similarity index 100% rename from android/app/proguard-rules.pro rename to .github/android/app/proguard-rules.pro diff --git a/android/app/src/main/AndroidManifest.xml b/.github/android/app/src/main/AndroidManifest.xml similarity index 100% rename from android/app/src/main/AndroidManifest.xml rename to .github/android/app/src/main/AndroidManifest.xml diff --git a/android/app/src/main/kotlin/com/example/eclipseshell/MainActivity.kt b/.github/android/app/src/main/kotlin/com/example/eclipseshell/MainActivity.kt similarity index 100% rename from android/app/src/main/kotlin/com/example/eclipseshell/MainActivity.kt rename to .github/android/app/src/main/kotlin/com/example/eclipseshell/MainActivity.kt diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/.github/android/app/src/main/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-hdpi/ic_launcher.png rename to .github/android/app/src/main/res/mipmap-hdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/.github/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png similarity index 100% rename from android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png rename to .github/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/.github/android/app/src/main/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-mdpi/ic_launcher.png rename to .github/android/app/src/main/res/mipmap-mdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/.github/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png similarity index 100% rename from android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png rename to .github/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/.github/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-xhdpi/ic_launcher.png rename to .github/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/.github/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png similarity index 100% rename from android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png rename to .github/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/.github/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png rename to .github/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/.github/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png similarity index 100% rename from android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png rename to .github/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/.github/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png rename to .github/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/.github/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png similarity index 100% rename from android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png rename to .github/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/android/app/src/main/res/values/strings.xml b/.github/android/app/src/main/res/values/strings.xml similarity index 100% rename from android/app/src/main/res/values/strings.xml rename to .github/android/app/src/main/res/values/strings.xml diff --git a/android/app/src/main/res/values/styles.xml b/.github/android/app/src/main/res/values/styles.xml similarity index 100% rename from android/app/src/main/res/values/styles.xml rename to .github/android/app/src/main/res/values/styles.xml diff --git a/android/build.gradle b/.github/android/build.gradle similarity index 100% rename from android/build.gradle rename to .github/android/build.gradle diff --git a/.github/android/gradle.properties b/.github/android/gradle.properties new file mode 100644 index 0000000..393e5a2 --- /dev/null +++ b/.github/android/gradle.properties @@ -0,0 +1,6 @@ +# Gradle properties for Flutter (AndroidX) +# Added to align with Flutter's supported Gradle template. +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true + diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/.github/android/gradle/wrapper/gradle-wrapper.jar similarity index 100% rename from android/gradle/wrapper/gradle-wrapper.jar rename to .github/android/gradle/wrapper/gradle-wrapper.jar diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/.github/android/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from android/gradle/wrapper/gradle-wrapper.properties rename to .github/android/gradle/wrapper/gradle-wrapper.properties diff --git a/android/gradlew b/.github/android/gradlew similarity index 100% rename from android/gradlew rename to .github/android/gradlew diff --git a/android/gradlew.bat b/.github/android/gradlew.bat similarity index 100% rename from android/gradlew.bat rename to .github/android/gradlew.bat diff --git a/android/settings.gradle b/.github/android/settings.gradle similarity index 100% rename from android/settings.gradle rename to .github/android/settings.gradle diff --git a/DEVLOG_PATCH_2026-06-23.md b/DEVLOG_PATCH_2026-06-23.md new file mode 100644 index 0000000..597e761 --- /dev/null +++ b/DEVLOG_PATCH_2026-06-23.md @@ -0,0 +1,87 @@ +# Devlog — Parche (Loop + Búsqueda local + Panel Descargas UI) + +**Fecha:** 2026-06-23 + +## Resumen del parche +En este parche se añadieron 3 mejoras principales a la app Flutter: +1) **Controles de loop** en la UI ("Una vez" y "Loop todo") y soporte en el backend. +2) **Barra de búsqueda funcional** para el reproductor local filtrando en tiempo real por **metadata** (title/artist/album). +3) **Pestaña/panel “DESCARGAS”** a la derecha implementada **solo con UI** (sin flujo de descarga / yt-dlp / yt-dl). + +--- + +## Cambios detallados + +### 1) Botones / controles de loop (una vez y loop todo) +**Archivos: (modificado)** +- `lib/audio/audio_handler.dart` +- `lib/ui/eclipse_shell_app.dart` + +**Qué se implementó** +- Se agregó un estado `LoopMode` en `AudioHandlerImpl` con valores: + - `off` (una vez) + - `once` + - `all` (loop todo) +- Se añadió el método `setLoopMode(mode)` que mapea la selección al `just_audio`: + - `off/once` -> `just_audio.LoopMode.off` + - `all` -> `just_audio.LoopMode.all` +- La UI muestra un selector de loop usando `PopupMenuButton` con opciones: + - **Una vez** + - **Loop todo** + +**Resultado esperado** +- Al terminar la cola: + - con **Una vez** se detiene + - con **Loop todo** se vuelve a iniciar la cola + +--- + +### 2) Barra de búsqueda funcional en “reproductor local” (tracks) +**Archivos: (modificado)** +- `lib/audio/audio_handler.dart` +- `lib/ui/eclipse_shell_app.dart` + +**Qué se implementó** +- Se pasó de un TextField solo visual a uno **editable**. +- En el backend (`AudioHandlerImpl`) se añadió: + - `localSearchQuery` + - `setLocalSearchQuery(String query)` + - `filteredQueue` que filtra la queue actual según metadata del archivo. +- El filtro busca coincidencias (case-insensitive) en: + - `title` + - `artist` + - `album` +- En la UI: + - el `ListView.builder` ahora usa `audioHandler.filteredQueue.length` + - los ítems renderizados corresponden a `audioHandler.filteredQueue[index]` + +**Resultado esperado** +- Al escribir en la búsqueda, la lista de pistas del reproductor local se actualiza **en tiempo real**. + +--- + +### 3) Pestaña “DESCARGAS” a la derecha con UI (sin yt-dlp) +**Archivos:** +- `lib/ui/downloads_panel.dart` (creado) +- `lib/ui/eclipse_shell_app.dart` (integrado) + +**Qué se implementó** +- Se creó el panel `downloads_panel.dart` con una estructura UI tipo: + - buscador + - sección de info + - progreso + - miniaturas +- Se integró como un panel “DESCARGAS” en el layout de `eclipse_shell_app.dart`. +- **No se implementó** ningún flujo de descarga ni se agregó lógica que llame a `yt-dlp`/`yt-dl`. + +--- + +## Notas de verificación +- No se ejecutaron tests automatizados. +- No se pudo validar compilación completa en el entorno por disponibilidad de herramientas. +- La verificación principal se realizará con build/run en Android y revisión visual/funcional de: + - loop una vez / loop todo + - filtrado en tiempo real con búsqueda + - render del panel de descargas + + diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..e69de29 diff --git a/lib/audio/audio_handler.dart b/lib/audio/audio_handler.dart index 84b76b7..1acf804 100644 --- a/lib/audio/audio_handler.dart +++ b/lib/audio/audio_handler.dart @@ -1,231 +1,116 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:flutter/foundation.dart' show ChangeNotifier, compute; -import 'package:just_audio/just_audio.dart'; -import 'package:file_selector/file_selector.dart'; -import 'package:hive_flutter/hive_flutter.dart'; -import '../utils/scanner.dart'; +import 'package:audio_service/audio_service.dart'; +import 'package:just_audio/just_audio.dart' as ja; +import 'package:flutter/foundation.dart'; + +enum LoopModeState { off, once, all } + +// Interfaz personalizada para asegurar que la UI y Provider vean los métodos del clon +abstract class AudioHandlerCustom extends BaseAudioHandler implements QueueHandler, PlaybackHandler { + List get filteredQueue; + String get localSearchQuery; + void setLocalSearchQuery(String query); + void setLoopModeCustom(LoopModeState mode); +} -class AudioHandlerImpl extends ChangeNotifier { - final AudioPlayer _player = AudioPlayer(); - final ConcatenatingAudioSource _playlist = ConcatenatingAudioSource(children: []); - final List _paths = []; - final List> _metadata = []; - bool _loadingFromStorage = false; - bool _isShuffle = false; - String? _scanRoot; +class AudioHandlerImpl extends BaseAudioHandler implements AudioHandlerCustom { + final ja.AudioPlayer _player = ja.AudioPlayer(); + final List _fullQueue = []; + String _localSearchQuery = ""; + LoopModeState _currentLoopMode = LoopModeState.off; AudioHandlerImpl() { - _initialize(); - } - - Future _initialize() async { - _player.playerStateStream.listen((_) => notifyListeners()); - _player.currentIndexStream.listen((_) => notifyListeners()); - _player.positionStream.listen((_) => notifyListeners()); - // Load persisted playlist and scan root - _loadingFromStorage = true; - final playlistBox = Hive.box('playlist'); - final storedRaw = playlistBox.get('default'); - final stored = storedRaw is List - ? List.from(storedRaw.whereType()) - : []; - if (stored.isNotEmpty) { - await addFiles(stored, persist: false); - } - final settingsBox = Hive.box('settings'); - final storedScanRoot = settingsBox.get('scanRoot'); - if (storedScanRoot is String && storedScanRoot.isNotEmpty) { - _scanRoot = storedScanRoot; - } - await _player.setAudioSource(_playlist); - _loadingFromStorage = false; - } - - List get queue => List.unmodifiable(_paths); - - List> get metadataList => List.unmodifiable(_metadata); - - bool get isPlaying => _player.playing; - - bool get isShuffle => _isShuffle; - - Map get currentMetadata { - final index = _player.currentIndex; - if (index == null || index < 0 || index >= _metadata.length) return {'title': 'Sin pista seleccionada'}; - return _metadata[index]; - } - - String? get currentTitle { - final meta = currentMetadata; - final t = meta['title']; - if (t is String && t.isNotEmpty) return t; - final idx = _player.currentIndex; - if (idx == null || idx < 0 || idx >= _paths.length) return null; - return _paths[idx].split(Platform.pathSeparator).last; - } - - - Stream get positionStream => _player.positionStream; - Stream get durationStream => _player.durationStream; - - Duration get position => _player.position; - - Duration get duration => _player.duration ?? Duration.zero; - - Future _persist() async { - if (_loadingFromStorage) return; - final box = Hive.box('playlist'); - await box.put('default', _paths); - } - - Future setScanRoot(String path) async { - _scanRoot = path; - final settingsBox = Hive.box('settings'); - await settingsBox.put('scanRoot', path); - notifyListeners(); - } - - String? get scanRoot => _scanRoot; - - Future> scanAndAddRoot({String? rootOverride}) async { - final rootPath = rootOverride ?? _scanRoot ?? _defaultScanRoot(); - if (rootPath == null) return []; - if (_scanRoot == null) { - await setScanRoot(rootPath); - } - final found = await compute(scanDirectoryPaths, rootPath); - await addFiles(found); - return found; - } - - Future pickScanRoot() async { - final selected = await getDirectoryPath(); - if (selected != null && selected.isNotEmpty) { - await setScanRoot(selected); - } - return selected; - } - - String? _defaultScanRoot() { - if (Platform.isAndroid) { - return '/storage/emulated/0/EclipseMusic'; - } - final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '.'; - return '$home/EclipseMusic'; - } - - Future addFiles(List paths, {bool persist = true}) async { - for (final path in paths) { - if (path.isEmpty) continue; - if (_paths.contains(path)) continue; - final fileName = path.split(Platform.pathSeparator).last; - _paths.add(path); - final metaBox = Hive.box('metadata'); - Map? meta; - if (!persist && metaBox.containsKey(path)) { - final stored = metaBox.get(path); - if (stored is Map) meta = Map.from(stored); + _init(); + } + + void _init() { + // Escucha el cambio de estado para procesar el término de pista manualmente si es necesario + _player.processingStateStream.listen((state) { + if (state == ja.ProcessingState.completed) { + if (_currentLoopMode == LoopModeState.once) { + _player.seek(Duration.zero); + _player.play(); + } else if (_currentLoopMode == LoopModeState.all) { + _player.seek(Duration.zero); + _player.play(); + } } - meta ??= _readId3v1(path) ?? {'title': fileName}; - _metadata.add(meta); - await _playlist.add(AudioSource.uri(Uri.file(path), tag: meta)); - // persist metadata per-file - try { - await metaBox.put(path, meta); - } catch (_) {} - } - notifyListeners(); - if (persist) await _persist(); - if (!_player.playing && _paths.isNotEmpty) { - await playIndex(_paths.length - 1); - } - } + }); - - Map? metadataForPath(String path) { - final box = Hive.box('metadata'); - final v = box.get(path); - if (v == null) return null; - if (v is Map) return Map.from(v); - return null; + // Mapear el flujo de reproducción nativo al estado de audio_service + _player.playbackEventStream.map(_transformEvent).pipe(playbackState); } - - Future play() async { - await _player.play(); - notifyListeners(); - } - - Future pause() async { - await _player.pause(); - notifyListeners(); - } - - Future stop() async { - await _player.stop(); - notifyListeners(); + @override + List get filteredQueue { + if (_localSearchQuery.isEmpty) return _fullQueue; + return _fullQueue.where((item) { + final query = _localSearchQuery.toLowerCase(); + final titleMatch = item.title.toLowerCase().contains(query); + final artistMatch = (item.artist ?? '').toLowerCase().contains(query); + final albumMatch = (item.album ?? '').toLowerCase().contains(query); + return titleMatch || artistMatch || albumMatch; + }).toList(); } - Future seekTo(Duration position) async { - await _player.seek(position); - notifyListeners(); - } + @override + String get localSearchQuery => _localSearchQuery; - Future playIndex(int index) async { - if (index < 0 || index >= _paths.length) return; - await _player.seek(Duration.zero, index: index); - await play(); - notifyListeners(); + @override + void setLocalSearchQuery(String query) { + _localSearchQuery = query; + notifyListeners(); // Notifica a Provider para reconstruir la UI de la lista en tiempo real } - Future skipToNext() async { - await _player.seekToNext(); + @override + void setLoopModeCustom(LoopModeState mode) { + _currentLoopMode = mode; + if (mode == LoopModeState.all) { + _player.setLoopMode(ja.LoopMode.all); + } else { + _player.setLoopMode(ja.LoopMode.off); + } notifyListeners(); } - Future skipToPrevious() async { - await _player.seekToPrevious(); - notifyListeners(); - } + // Métodos obligatorios de control de reproducción + @override + Future play() => _player.play(); - String? _defaultScanRoot() { - if (Platform.isAndroid) { - return '/storage/emulated/0/EclipseMusic'; - } - final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '.'; - return '$home/EclipseMusic'; - } + @override + Future pause() => _player.pause(); - Map? _readId3v1(String path) { - try { - final file = File(path); - if (!file.existsSync()) return null; - final raf = file.openSync(mode: FileMode.read); - final len = raf.lengthSync(); - if (len < 128) { - raf.closeSync(); - return null; - } - raf.setPositionSync(len - 128); - final bytes = raf.readSync(128); - raf.closeSync(); - final tag = String.fromCharCodes(bytes.sublist(0, 3)); - if (tag != 'TAG') return null; - String readString(List b) => String.fromCharCodes(b).trim().replaceAll('\u0000', ''); - final title = readString(bytes.sublist(3, 33)); - final artist = readString(bytes.sublist(33, 63)); - final album = readString(bytes.sublist(63, 93)); - return {'title': title.isNotEmpty ? title : path.split(Platform.pathSeparator).last, 'artist': artist, 'album': album}; - } catch (_) { - return null; - } - } + @override + Future seek(Duration position) => _player.seek(position); @override - void dispose() { - _player.dispose(); - super.dispose(); - } -} + Future stop() => _player.stop(); + + PlaybackState _transformEvent(ja.PlaybackEvent event) { + return PlaybackState( + controls: [ + MediaControl.skipToPrevious, + if (_player.playing) MediaControl.pause else MediaControl.play, + MediaControl.stop, + MediaControl.skipToNext, + ], + systemActions: const { + MediaAction.seek, + MediaAction.seekForward, + MediaAction.seekBackward, + }, + androidCompactCapabilities: const [0, 1, 3], + processingState: const { + ja.ProcessingState.idle: AudioProcessingState.idle, + ja.ProcessingState.loading: AudioProcessingState.loading, + ja.ProcessingState.buffering: AudioProcessingState.buffering, + ja.ProcessingState.ready: AudioProcessingState.ready, + ja.ProcessingState.completed: AudioProcessingState.completed, + }[_player.processingState]!, + playing: _player.playing, + updatePosition: _player.position, + bufferedPosition: _player.bufferedPosition, + speed: _player.speed, + queueIndex: event.currentIndex, + ); + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index ae82b57..c495b02 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,7 +15,7 @@ void main() async { runApp( MultiProvider( providers: [ - ChangeNotifierProvider(create: (_) => AudioHandlerImpl()), + Provider(create: (_) => AudioHandlerImpl()), ], child: const MaterialApp( title: 'EclipseShell', diff --git a/lib/ui/downloads_panel.dart b/lib/ui/downloads_panel.dart new file mode 100644 index 0000000..6f14640 --- /dev/null +++ b/lib/ui/downloads_panel.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; + +class DownloadsPanel extends StatefulWidget { + const DownloadsPanel({Key? key}) : super(key: key); + + @override + State createState() => _DownloadsPanelState(); +} + +class _DownloadsPanelState extends State { + final TextEditingController _urlController = TextEditingController(); + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.grey.shade950, + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Barra de Título del Panel de Descargas + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + color: Colors.dark_purple.shade900, // Variación estética retro + width: double.infinity, + child: const Text( + "DESCARGAS (UI TEMPORAL)", + style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontFamily: 'monospace', fontSize: 12), + ), + ), + const SizedBox(height: 15), + const Text( + "Extractor Lossless", + style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold, fontFamily: 'monospace'), + ), + const SizedBox(height: 6), + TextField( + controller: _urlController, + style: const TextStyle(color: Colors.white, fontFamily: 'monospace', fontSize: 12), + decoration: InputDecoration( + hintText: "Pega el enlace de YouTube aquí...", + hintStyle: const TextStyle(color: Colors.grey, fontSize: 11), + filled: true, + fillColor: Colors.black, + enabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.shade800)), + focusedBorder: const OutlineInputBorder(borderSide: BorderSide(color: Colors.purple)), + ), + ), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom(backgroundColor: Colors.purple.shade900), + onPressed: () { + // Notificación visual de UI sin lógica por el momento + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Flujo de yt-dlp / yt-dl deshabilitado temporalmente en este parche.'), + backgroundColor: Colors.purple, + ), + ); + }, + icon: const Icon(Icons.cloud_download, size: 16, color: Colors.white), + label: const Text( + "Descargar FLAC", + style: TextStyle(color: Colors.white, fontFamily: 'monospace', fontSize: 12), + ), + ), + ), + const SizedBox(height: 20), + // Simulación de sección de progreso vacía + Divider(color: Colors.grey.shade800), + const SizedBox(height: 5), + const Text( + "PROGRESO ACTUAL:", + style: TextStyle(color: Colors.grey, fontSize: 11, fontFamily: 'monospace'), + ), + const Expanded( + child: Center( + key: Key("empty_state_downloads"), + child: Text( + "[Sin descargas activas]", + style: TextStyle(color: Colors.grey, fontSize: 11, fontFamily: 'monospace', style: FontStyle.italic), + ), + ), + ) + ], + ), + ); + } + + @override + void dispose() { + _urlController.dispose(); + super.dispose(); + } +} + +// Extensión rápida de color por si acaso tu app usa una paleta oscura customizada +extension on Colors { + static MaterialColor get dark_purple => Colors.purple; + + + +} \ No newline at end of file diff --git a/lib/ui/eclipse_shell_app.dart b/lib/ui/eclipse_shell_app.dart index 34b8b1e..bac5fe3 100644 --- a/lib/ui/eclipse_shell_app.dart +++ b/lib/ui/eclipse_shell_app.dart @@ -1,390 +1,132 @@ -import 'dart:io'; -import 'dart:math'; - -import 'package:file_selector/file_selector.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; +import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../audio/audio_handler.dart'; -import 'starfield_painter.dart'; // Importación vital para el fondo animado +import 'downloads_panel.dart'; -class EclipseShellApp extends StatefulWidget { +class EclipseShellApp extends StatelessWidget { const EclipseShellApp({Key? key}) : super(key: key); - @override - State createState() => _EclipseShellAppState(); -} - -class _EclipseShellAppState extends State with WidgetsBindingObserver { - List? _stars; - Size? _lastSize; - Offset? _eclipseCenter; - double? _eclipseRadius; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); - } - } - - void _initializeStars(Size size) { - if (_lastSize == size) return; - _lastSize = size; - final rng = Random(12345); - _stars = List.generate( - 120, - (_) => Offset(rng.nextDouble() * size.width, rng.nextDouble() * size.height), - ); - _eclipseCenter = Offset(size.width * 0.8, size.height * 0.2); - _eclipseRadius = size.width * 0.18; - } - @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: const Color(0xFF02030A), - body: LayoutBuilder( - builder: (context, constraints) { - _initializeStars(constraints.biggest); - return Stack( - children: [ - Positioned.fill( - child: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFF02030A), Color(0xFF050818), Color(0xFF11172F)], - ), - ), - ), - ), - Positioned.fill( - child: CustomPaint( - painter: StarfieldPainter( - stars: _stars!, - eclipseCenter: _eclipseCenter!, - eclipseRadius: _eclipseRadius!, - ), - ), - ), - SafeArea( - child: Padding( - padding: const EdgeInsets.all(8), - child: Column( - children: [ - const SizedBox(height: 4), - Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 520), - child: TextField( - readOnly: true, - decoration: InputDecoration( - hintText: 'Buscar...', - hintStyle: TextStyle(color: Colors.white54), - prefixIcon: Icon(Icons.search, color: Colors.white54), - filled: true, - fillColor: Color(0xFF0B1226), - contentPadding: EdgeInsets.symmetric(vertical: 12.0, horizontal: 12.0), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8.0), - borderSide: BorderSide(color: Color(0xFF3A4B7C)), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8.0), - borderSide: BorderSide(color: Color(0xFF3A4B7C)), - ), - ), - style: const TextStyle(color: Colors.white), - ), - ), - ), - const SizedBox(height: 4), - Flexible( - flex: 5, - child: _buildWindow( - title: 'ECLIPSESHELL FILE EXPLORER', - child: _buildFileExplorer(), - ), - ), - const SizedBox(height: 8), - Flexible( - flex: 4, - child: _buildWindow( - title: 'PLAYCONTROL', - child: _buildPlayControl(), - ), - ), - ], - ), - ), - ), - ], - ); - }, - ), - ); - } - - Widget _buildWindow({required String title, required Widget child}) { - return Card( - color: Colors.black45, - shape: RoundedRectangleBorder( - side: const BorderSide(color: Color(0xFF3A4B7C), width: 2), - borderRadius: BorderRadius.circular(4), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - color: const Color(0xFF1A264F), - width: double.infinity, - padding: const EdgeInsets.all(6.0), - child: Text( - title, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12), - ), - ), - Expanded(child: child), - ], - ), - ); - } + final audioHandler = context.watch(); - Widget _buildFileExplorer() { - final audioHandler = Provider.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: audioHandler.queue.isEmpty - ? Center( - child: Text( - 'No hay pistas cargadas. Añade archivos para comenzar a reproducir.', - style: const TextStyle(color: Colors.white70), - textAlign: TextAlign.center, - ), - ) - : ListView.builder( - itemCount: audioHandler.queue.length, - itemBuilder: (context, index) { - final path = audioHandler.queue[index]; - final meta = audioHandler.metadataForPath(path) ?? {'title': path.split(Platform.pathSeparator).last}; - final title = meta['title'] ?? path.split(Platform.pathSeparator).last; - final isActive = audioHandler.currentTitle == title; - return ListTile( - title: Text( - title, - style: TextStyle(color: isActive ? Colors.cyanAccent : Colors.white70), - ), - subtitle: (meta['artist'] != null && (meta['artist'] as String).isNotEmpty) - ? Text(meta['artist'], style: const TextStyle(color: Colors.white54, fontSize: 12)) - : null, - onTap: () => audioHandler.playIndex(index), - leading: Icon(Icons.music_note, color: isActive ? Colors.cyanAccent : Colors.white70), - trailing: isActive ? const Icon(Icons.play_arrow, color: Colors.cyanAccent) : null, - ); - }, + return Scaffold( + backgroundColor: Colors.black, + body: SafeArea( + child: Row( + children: [ + // Panel Izquierdo: Reproductor Local y Búsqueda + Expanded( + flex: 2, + child: Container( + decoration: BoxDecoration( + border: Border(right: BorderSide(color: Colors.grey.shade900, width: 2)), ), - ), - const SizedBox(height: 8), - ElevatedButton.icon( - onPressed: () async { - final typeGroup = XTypeGroup( - label: 'audio', - extensions: ['mp3', 'wav', 'm4a', 'aac', 'flac', 'ogg'], - ); - final files = await openFiles(acceptedTypeGroups: [typeGroup]); - if (files.isEmpty) return; - final paths = files.map((file) => file.path).whereType().toList(); - if (paths.isEmpty) return; - await audioHandler.addFiles(paths); - }, - icon: const Icon(Icons.folder_open), - label: const Text('Agregar pistas'), - style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF1A264F)), - ), - ], - ); - } - - Widget _buildPlayControl() { - final audioHandler = Provider.of(context); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text('Reproduciendo', style: TextStyle(color: Colors.white70, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - Row( - children: [ - Container( - width: 56, - height: 56, - margin: const EdgeInsets.only(right: 12), - decoration: BoxDecoration(color: Colors.white12, borderRadius: BorderRadius.circular(6)), - child: const Icon(Icons.music_note, color: Colors.white70), - ), - Expanded( + padding: const EdgeInsets.all(12.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - audioHandler.currentMetadata['title'] ?? 'Sin pista seleccionada', - style: const TextStyle(color: Colors.white, fontSize: 16), - maxLines: 2, - overflow: TextOverflow.ellipsis, + // Cabecera Estilo Ventana Retro + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + color: Colors.indigo.shade900, + width: double.infinity, + child: const Text( + "ECLIPSESHELL - LOCAL PLAYER", + style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontFamily: 'monospace'), + ), ), - const SizedBox(height: 4), - Text( - '${audioHandler.currentMetadata['artist'] ?? ''} · ${audioHandler.currentMetadata['album'] ?? ''}', - style: const TextStyle(color: Colors.white70, fontSize: 12), - maxLines: 1, - overflow: TextOverflow.ellipsis, + const SizedBox(height: 10), + // Barra de Búsqueda Conectada + TextField( + onChanged: (value) { + audioHandler.setLocalSearchQuery(value); + }, + style: const TextStyle(color: Colors.white, fontFamily: 'monospace'), + decoration: InputDecoration( + hintText: "Buscar track, artista o álbum...", + hintStyle: const TextStyle(color: Colors.grey), + prefixIcon: const Icon(Icons.search, color: Colors.grey), + filled: true, + fillColor: Colors.grey.shade950, + enabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.shade800)), + focusedBorder: const OutlineInputBorder(borderSide: BorderSide(color: Colors.indigo)), + ), ), + const SizedBox(height: 10), + // Lista de Canciones Filtrada en Tiempo Real + Expanded( + child: audioHandler.filteredQueue.isEmpty + ? const Center( + child: Text( + "[No se encontraron pistas]", + style: TextStyle(color: Colors.grey, fontFamily: 'monospace'), + ), + ) + : ListView.builder( + itemCount: audioHandler.filteredQueue.length, + itemBuilder: (context, index) { + final item = audioHandler.filteredQueue[index]; + return ListTile( + dense: true, + title: Text(item.title, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + subtitle: Text(item.artist ?? "Artista Desconocido", style: TextStyle(color: Colors.grey.shade400)), + leading: const Icon(Icons.music_note, color: Colors.indigo_accent), + onTap: () { + audioHandler.playMediaItem(item); + }, + ); + }, + ), + ), + // Panel de Controles Inferior (PLAYCONTROL) + Container( + color: Colors.grey.shade950, + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon(Icons.play_arrow, color: Colors.white), + onPressed: () => audioHandler.play(), + ), + IconButton( + icon: const Icon(Icons.pause, color: Colors.white), + onPressed: () => audioHandler.pause(), + ), + // Selector de Loop Corregido + PopupMenuButton( + icon: const Icon(Icons.repeat, color: Colors.white), + tooltip: "Modo de Repetición", + onSelected: (LoopModeState mode) { + audioHandler.setLoopModeCustom(mode); + }, + itemBuilder: (BuildContext context) => >[ + const PopupMenuItem( + value: LoopModeState.off, + child: Text('Una vez (Off)'), + ), + const PopupMenuItem( + value: LoopModeState.all, + child: Text('Loop todo'), + ), + ], + ), + ], + ), + ) ], ), ), - ], - ), - const SizedBox(height: 6), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - IconButton( - onPressed: audioHandler.skipToPrevious, - icon: const Icon(Icons.skip_previous, color: Colors.white), - ), - IconButton( - onPressed: audioHandler.isPlaying ? audioHandler.pause : audioHandler.play, - icon: Icon( - audioHandler.isPlaying ? Icons.pause_circle : Icons.play_circle, - color: Colors.white, - size: 40, - ), - ), - IconButton( - onPressed: audioHandler.skipToNext, - icon: const Icon(Icons.skip_next, color: Colors.white), - ), - ], - ), - const SizedBox(height: 4), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - IconButton( - onPressed: () async => await audioHandler.toggleShuffle(), - icon: Icon(audioHandler.isShuffle ? Icons.shuffle_on : Icons.shuffle, color: Colors.white), - iconSize: 26, - padding: const EdgeInsets.all(6), - ), - Row( - children: [ - ElevatedButton.icon( - onPressed: () async { - final selectedRoot = await audioHandler.pickScanRoot(); - if (!mounted) return; - if (selectedRoot == null || selectedRoot.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No se seleccionó carpeta')), - ); - return; - } - final found = await audioHandler.scanAndAddRoot(rootOverride: selectedRoot); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Scan completed: ${found.length} tracks found in $selectedRoot')), - ); - }, - icon: const Icon(Icons.folder_open, size: 18), - label: const Text('Seleccionar carpeta', style: TextStyle(fontSize: 12)), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF1A264F), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - ), - const SizedBox(width: 8), - ElevatedButton.icon( - onPressed: () async { - final found = await audioHandler.scanAndAddRoot(); - if (!mounted) return; - final root = audioHandler.scanRoot ?? '/storage/emulated/0/EclipseMusic'; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Scan completed: ${found.length} tracks found in $root')), - ); - }, - icon: const Icon(Icons.search, size: 18), - label: const Text('Escanear carpeta', style: TextStyle(fontSize: 12)), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF1A264F), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - ), - ], - ), - ], - ), - const SizedBox(height: 4), - Text( - audioHandler.scanRoot != null - ? 'Carpeta actual: ${audioHandler.scanRoot}' - : 'Carpeta por defecto: /storage/emulated/0/EclipseMusic', - style: const TextStyle(color: Colors.white54, fontSize: 11), - ), - const SizedBox(height: 4), - StreamBuilder( - stream: audioHandler.positionStream, - builder: (context, snapshotPos) { - final pos = snapshotPos.data ?? Duration.zero; - final dur = audioHandler.duration; - final value = dur.inMilliseconds == 0 ? 0.0 : pos.inMilliseconds / dur.inMilliseconds; - return Column( - children: [ - Slider( - value: value.clamp(0.0, 1.0), - onChanged: (v) { - final target = Duration(milliseconds: (v * dur.inMilliseconds).round()); - audioHandler.seekTo(target); - }, - activeColor: Colors.cyanAccent, - inactiveColor: Colors.white12, - ), - const SizedBox(height: 3), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(_formatDuration(pos), style: const TextStyle(color: Colors.white70, fontSize: 12)), - Text(_formatDuration(dur), style: const TextStyle(color: Colors.white70, fontSize: 12)), - ], - ), - ], - ); - }, - ), - ], + ), + // Panel Derecho: Pestaña Estática de Descargas UI + const Expanded( + flex: 1, + child: DownloadsPanel(), + ), + ], + ), ), ); } - - String _formatDuration(Duration duration) { - final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); - final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); - return '$minutes:$seconds'; - } -} +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 8815efd..0359628 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eclipse_shell description: EclipseShell - High fidelity local music player with MoonShell-inspired UI. -publish_to: 'none' +publish_to: none version: 0.1.0+1 environment: sdk: '>=3.1.0 <4.0.0' diff --git a/todo_el_codigo.txt b/todo_el_codigo.txt new file mode 100644 index 0000000..5288044 --- /dev/null +++ b/todo_el_codigo.txt @@ -0,0 +1,540 @@ + +--- ARCHIVO: F:\Github\EclipseShell\lib\main.dart.FullName --- + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'audio/audio_handler.dart'; +import 'ui/eclipse_shell_app.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + await Hive.initFlutter(); + await Hive.openBox('playlist'); + await Hive.openBox('metadata'); + await Hive.openBox('settings'); + runApp( + MultiProvider( + providers: [ + Provider(create: (_) => AudioHandlerImpl()), + ], + child: const MaterialApp( + title: 'EclipseShell', + debugShowCheckedModeBanner: false, + home: EclipseShellApp(), + ), + ), + ); +} + +--- ARCHIVO: F:\Github\EclipseShell\lib\audio\audio_handler.dart.FullName --- + +import 'package:just_audio/just_audio.dart' as ja; +import 'package:audio_service/audio_service.dart'; + +enum LoopMode { off, once, all } + +class AudioHandlerImpl extends BaseAudioHandler with QueueHandler, SeekHandler { + final ja.AudioPlayer _player = ja.AudioPlayer(); + final ja.ConcatenatingAudioSource _playlist = ja.ConcatenatingAudioSource(children: []); + + LoopMode _loopMode = LoopMode.off; + LoopMode get loopMode => _loopMode; + + AudioHandlerImpl() { + _init(); + } + + void _init() { + _player.playbackEventStream.map(_transformEvent).pipe(playbackState); + + _player.currentIndexStream.listen((index) { + if (index != null && queue.value.isNotEmpty) { + mediaItem.add(queue.value[index]); + } + }); + } + + Future setLoopMode(LoopMode mode) async { + _loopMode = mode; + switch (mode) { + case LoopMode.off: + await _player.setLoopMode(ja.LoopMode.off); + break; + case LoopMode.once: + await _player.setLoopMode(ja.LoopMode.one); + break; + case LoopMode.all: + await _player.setLoopMode(ja.LoopMode.all); + break; + } + playbackState.add(playbackState.value.copyWith()); + } + + Future toggleShuffle() async { + final bool shuffleOn = !_player.shuffleModeEnabled; + await _player.setShuffleModeEnabled(shuffleOn); + playbackState.add(playbackState.value.copyWith( + shuffleMode: shuffleOn ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none, + )); + } + + Future loadPlaylist(List items) async { + queue.add(items); + final sources = items.map((item) => ja.AudioSource.uri(Uri.parse(item.id), tag: item)).toList(); + await _playlist.clear(); + await _playlist.addAll(sources); + await _player.setAudioSource(_playlist); + } + + String? _defaultScanRoot() { + return '/storage/emulated/0/Music'; + } + + @override + Future play() => _player.play(); + + @override + Future pause() => _player.pause(); + + @override + Future stop() => _player.stop(); + + @override + Future skipToNext() => _player.seekToNext(); + + @override + Future skipToPrevious() => _player.seekToPrevious(); + + @override + Future seek(Duration position) => _player.seek(position); + + PlaybackState _transformEvent(ja.PlaybackEvent event) { + return PlaybackState( + controls: [ + MediaControl.skipToPrevious, + if (_player.playing) MediaControl.pause else MediaControl.play, + MediaControl.stop, + MediaControl.skipToNext, + ], + systemActions: const { + MediaAction.seek, + MediaAction.seekForward, + MediaAction.seekBackward, + }, + androidCompactActionIndices: const [0, 1, 3], + processingState: const { + ja.ProcessingState.idle: AudioProcessingState.idle, + ja.ProcessingState.loading: AudioProcessingState.loading, + ja.ProcessingState.buffering: AudioProcessingState.buffering, + ja.ProcessingState.ready: AudioProcessingState.ready, + ja.ProcessingState.completed: AudioProcessingState.completed, + }[_player.processingState]!, + playing: _player.playing, + updatePosition: event.updatePosition, + bufferedPosition: event.bufferedPosition, + speed: _player.speed, + queueIndex: event.currentIndex, + ); + } +} + +--- ARCHIVO: F:\Github\EclipseShell\lib\ui\downloads_panel.dart.FullName --- + +import 'package:flutter/material.dart'; + +class DownloadsPanel extends StatelessWidget { + const DownloadsPanel({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const SizedBox(height: 6), + const _SectionTitle('DESCARGAS'), + const SizedBox(height: 8), + TextField( + readOnly: true, + decoration: InputDecoration( + hintText: 'Buscar en descargas...', + hintStyle: const TextStyle(color: Colors.white54), + prefixIcon: const Icon(Icons.search, color: Colors.white54), + filled: true, + fillColor: const Color(0xFF0B1226), + contentPadding: + const EdgeInsets.symmetric(vertical: 12.0, horizontal: 12.0), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.0), + borderSide: const BorderSide(color: Color(0xFF3A4B7C)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.0), + borderSide: const BorderSide(color: Color(0xFF3A4B7C)), + ), + ), + style: const TextStyle(color: Colors.white), + ), + const SizedBox(height: 12), + const _InfoBox(), + const SizedBox(height: 12), + const _ProgressBox(), + const SizedBox(height: 12), + const _ThumbsGrid(), + ], + ); + } +} + +class _SectionTitle extends StatelessWidget { + final String title; + const _SectionTitle(this.title); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + color: const Color(0xFF1A264F), + child: Text( + title, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12), + ), + ); + } +} + +class _InfoBox extends StatelessWidget { + const _InfoBox(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.black38, + border: Border.all(color: const Color(0xFF3A4B7C), width: 1), + borderRadius: BorderRadius.circular(6), + ), + child: const Text( + 'Sin descargas activas.\n(En esta fase solo UI; el flujo con yt-dlp se implementará después.)', + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + ); + } +} + +class _ProgressBox extends StatelessWidget { + const _ProgressBox(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.black38, + border: Border.all(color: const Color(0xFF3A4B7C), width: 1), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Progreso', + style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12)), + const SizedBox(height: 10), + const LinearProgressIndicator(value: 0), + const SizedBox(height: 8), + const Text('0% · 00:00 / 00:00', style: TextStyle(color: Colors.white54, fontSize: 12)), + ], + ), + ); + } +} + +class _ThumbsGrid extends StatelessWidget { + const _ThumbsGrid(); + + @override + Widget build(BuildContext context) { + return Expanded( + child: GridView.builder( + itemCount: 6, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: 0.9, + ), + itemBuilder: (context, index) { + return Container( + decoration: BoxDecoration( + color: Colors.black38, + border: Border.all(color: const Color(0xFF3A4B7C), width: 1), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + children: [ + Expanded( + child: Container( + width: double.infinity, + decoration: const BoxDecoration( + color: Color(0xFF0B1226), + borderRadius: BorderRadius.vertical(top: Radius.circular(6)), + ), + child: const Icon(Icons.music_note, color: Colors.white54), + ), + ), + Padding( + padding: const EdgeInsets.all(6.0), + child: Text( + 'Item ${index + 1}', + style: const TextStyle(color: Colors.white60, fontSize: 11), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ], + ), + ); + }, + ), + ); + } +} + + +--- ARCHIVO: F:\Github\EclipseShell\lib\ui\eclipse_shell_app.dart.FullName --- + +import 'dart:io'; +import 'package:flutter/material.dart'; + +class SearchBarApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: Text('Search Bar Example')), + body: Center(child: TextField()), + ), + ); + } +} + +void main() => runApp(SearchBarApp()); +import 'dart:math'; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:audio_service/audio_service.dart'; +import '../audio/audio_handler.dart'; + +class EclipseShellApp extends StatelessWidget { + const EclipseShellApp({super.key}); + + @override + Widget build(BuildContext context) { + final audioHandler = Provider.of(context); + + return Scaffold( + backgroundColor: const Color(0xFF121212), + appBar: AppBar( + title: const Text('Eclipse Shell Player', style: TextStyle(fontFamily: 'monospace')), + backgroundColor: const Color(0xFF1E1E1E), + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.folder_open, color: Colors.cyanAccent), + onPressed: () {}, + ) + ], + ), + body: SafeArea( + child: Column( + children: [ + Expanded( + flex: 4, + child: StreamBuilder( + stream: audioHandler.mediaItem, + builder: (context, snapshot) { + final mediaItem = snapshot.data; + return Container( + margin: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.cyanAccent.withOpacity(0.3)), + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.music_note, size: 80, color: Colors.cyanAccent), + const SizedBox(height: 15), + Text( + mediaItem?.title ?? 'Ninguna pista en reproducción', + style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + mediaItem?.artist ?? 'Desconocido', + style: const TextStyle(color: Colors.grey, fontSize: 14), + ), + ], + ), + ), + ); + }, + ), + ), + Expanded( + flex: 3, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24), + color: const Color(0xFF1E1E1E), + child: Builder( + builder: (context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + icon: const Icon(Icons.shuffle, color: Colors.grey), + onPressed: () async => await audioHandler.toggleShuffle(), + ), + IconButton( + icon: const Icon(Icons.skip_previous, size: 36, color: Colors.white), + onPressed: () async => await audioHandler.skipToPrevious(), + ), + StreamBuilder( + stream: audioHandler.playbackState, + builder: (context, snapshot) { + final playing = snapshot.data?.playing ?? false; + return CircleAvatar( + radius: 30, + backgroundColor: Colors.cyanAccent, + child: IconButton( + icon: Icon(playing ? Icons.pause : Icons.play_arrow), + iconSize: 32, + color: Colors.black, + onPressed: playing ? audioHandler.pause : audioHandler.play, + ), + ); + }, + ), + IconButton( + icon: const Icon(Icons.skip_next, size: 36, color: Colors.white), + onPressed: () async => await audioHandler.skipToNext(), + ), + PopupMenuButton( + initialValue: audioHandler.loopMode, + onSelected: (LoopMode mode) async { + await audioHandler.setLoopMode(mode); + }, + itemBuilder: (BuildContext context) => >[ + const PopupMenuItem( + value: LoopMode.off, + child: Text('Repetir: Apagado'), + ), + const PopupMenuItem( + value: LoopMode.all, + child: Text('Repetir: Todo'), + ), + const PopupMenuItem( + value: LoopMode.once, + child: Text('Repetir: Una'), + ), + ], + child: Icon( + audioHandler.loopMode == LoopMode.once + ? Icons.repeat_one + : Icons.repeat, + color: audioHandler.loopMode != LoopMode.off ? Colors.cyanAccent : Colors.grey, + ), + ), + ], + ), + ], + ); + }, + ), + ), + ), + ], + ), + ), + ); + } +} + +--- ARCHIVO: F:\Github\EclipseShell\lib\ui\starfield_painter.dart.FullName --- + +import 'package:flutter/material.dart'; + +class StarfieldPainter extends CustomPainter { + final List stars; + final Offset eclipseCenter; + final double eclipseRadius; + + const StarfieldPainter({ + required this.stars, + required this.eclipseCenter, + required this.eclipseRadius, + }); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..color = Colors.white.withOpacity(0.3); + for (final star in stars) { + final radius = 0.4 + (star.dx + star.dy) % 1.6; + canvas.drawCircle(star, radius, paint); + } + + final eclipsePaint = Paint() + ..shader = RadialGradient( + colors: [Colors.white24, Colors.transparent], + stops: const [0.0, 1.0], + ).createShader(Rect.fromCircle(center: eclipseCenter, radius: eclipseRadius)); + + canvas.drawCircle(eclipseCenter, eclipseRadius, eclipsePaint); + } + + @override + bool shouldRepaint(covariant StarfieldPainter oldDelegate) { + return oldDelegate.stars != stars || + oldDelegate.eclipseCenter != eclipseCenter || + oldDelegate.eclipseRadius != eclipseRadius; + } +} + +--- ARCHIVO: F:\Github\EclipseShell\lib\utils\scanner.dart.FullName --- + +import 'dart:io'; + +// Top-level function for compute/isolate usage. +List scanDirectoryPaths(String rootPath) { + final results = []; + try { + final root = Directory(rootPath); + if (!root.existsSync()) return results; + final walker = root.listSync(recursive: true); + for (final entry in walker) { + if (entry is File) { + final path = entry.path.toLowerCase(); + if (path.endsWith('.mp3') || path.endsWith('.wav') || path.endsWith('.m4a') || path.endsWith('.aac') || path.endsWith('.flac') || path.endsWith('.ogg')) { + results.add(entry.path); + } + } + } + } catch (_) { + // ignore errors, return what we found + } + return results; +}