From 8bae372215592e3bbc32eebae6e47391d7a81cfe Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Mon, 15 Dec 2025 00:40:07 +0800 Subject: [PATCH] :lipstick: Mixed import button --- lib/data/playlist_repository.dart | 2 +- lib/data/track_repository.dart | 2 +- lib/providers/audio_provider.dart | 3 +- lib/providers/db_provider.dart | 2 +- lib/ui/screens/album_detail_screen.dart | 6 +- lib/ui/screens/library_screen.dart | 103 ++++++++++++----- lib/ui/screens/player_screen.dart | 13 +-- lib/ui/screens/playlist_detail_screen.dart | 128 +++++++++++++++++++++ lib/ui/tabs/albums_tab.dart | 7 +- lib/ui/tabs/playlists_tab.dart | 17 ++- lib/ui/widgets/mini_player.dart | 6 +- 11 files changed, 231 insertions(+), 58 deletions(-) create mode 100644 lib/ui/screens/playlist_detail_screen.dart diff --git a/lib/data/playlist_repository.dart b/lib/data/playlist_repository.dart index 4c7cfbc..ed78d3b 100644 --- a/lib/data/playlist_repository.dart +++ b/lib/data/playlist_repository.dart @@ -1,6 +1,6 @@ import 'package:drift/drift.dart'; +import 'package:groovybox/providers/db_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../providers/db_provider.dart'; import 'db.dart'; part 'playlist_repository.g.dart'; diff --git a/lib/data/track_repository.dart b/lib/data/track_repository.dart index 4acb8a2..bb87ecb 100644 --- a/lib/data/track_repository.dart +++ b/lib/data/track_repository.dart @@ -1,10 +1,10 @@ import 'dart:io'; import 'package:flutter_media_metadata/flutter_media_metadata.dart'; +import 'package:groovybox/providers/db_provider.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:drift/drift.dart'; -import '../providers/db_provider.dart'; import 'db.dart'; part 'track_repository.g.dart'; diff --git a/lib/providers/audio_provider.dart b/lib/providers/audio_provider.dart index 60fd9f3..6747110 100644 --- a/lib/providers/audio_provider.dart +++ b/lib/providers/audio_provider.dart @@ -1,7 +1,6 @@ +import 'package:groovybox/logic/audio_handler.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../logic/audio_handler.dart'; - part 'audio_provider.g.dart'; @Riverpod(keepAlive: true) diff --git a/lib/providers/db_provider.dart b/lib/providers/db_provider.dart index 09bcf04..6ab194f 100644 --- a/lib/providers/db_provider.dart +++ b/lib/providers/db_provider.dart @@ -1,5 +1,5 @@ +import 'package:groovybox/data/db.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../data/db.dart'; part 'db_provider.g.dart'; diff --git a/lib/ui/screens/album_detail_screen.dart b/lib/ui/screens/album_detail_screen.dart index ae78d09..f303162 100644 --- a/lib/ui/screens/album_detail_screen.dart +++ b/lib/ui/screens/album_detail_screen.dart @@ -1,10 +1,10 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:groovybox/data/db.dart'; +import 'package:groovybox/data/playlist_repository.dart'; +import 'package:groovybox/providers/audio_provider.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:media_kit/media_kit.dart' hide Track; -import '../../data/playlist_repository.dart'; -import '../../data/db.dart'; -import '../../providers/audio_provider.dart'; class AlbumDetailScreen extends HookConsumerWidget { final AlbumData album; diff --git a/lib/ui/screens/library_screen.dart b/lib/ui/screens/library_screen.dart index b79a9b4..3eb0d02 100644 --- a/lib/ui/screens/library_screen.dart +++ b/lib/ui/screens/library_screen.dart @@ -3,19 +3,40 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; +import 'package:groovybox/data/db.dart'; +import 'package:groovybox/data/playlist_repository.dart'; +import 'package:groovybox/data/track_repository.dart'; +import 'package:groovybox/logic/lyrics_parser.dart'; +import 'package:groovybox/providers/audio_provider.dart'; +import 'package:groovybox/ui/tabs/albums_tab.dart'; +import 'package:groovybox/ui/tabs/playlists_tab.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import '../../data/track_repository.dart'; -import '../../providers/audio_provider.dart'; -import '../../data/playlist_repository.dart'; -import '../../data/db.dart'; -import '../../logic/lyrics_parser.dart'; -import '../tabs/albums_tab.dart'; -import '../tabs/playlists_tab.dart'; +import 'package:path/path.dart' as p; class LibraryScreen extends HookConsumerWidget { const LibraryScreen({super.key}); + static const List audioExtensions = [ + 'mp3', + 'm4a', + 'wav', + 'flac', + 'aac', + 'ogg', + 'wma', + 'm4p', + 'aiff', + 'au', + 'dss', + ]; + + static const List lyricsExtensions = ['lrc', 'srt', 'txt']; + + static const List allAllowedExtensions = [ + ...audioExtensions, + ...lyricsExtensions, + ]; + @override Widget build(BuildContext context, WidgetRef ref) { // We can define a stream provider locally or in repository file. @@ -81,6 +102,7 @@ class LibraryScreen extends HookConsumerWidget { ], ) : AppBar( + centerTitle: true, title: const Text('Library'), bottom: const TabBar( tabs: [ @@ -92,29 +114,52 @@ class LibraryScreen extends HookConsumerWidget { actions: [ IconButton( icon: const Icon(Icons.add_circle_outline), - tooltip: 'Add Tracks', + tooltip: 'Import Files', onPressed: () async { final result = await FilePicker.platform.pickFiles( - type: FileType.audio, + type: FileType.custom, + allowedExtensions: allAllowedExtensions, allowMultiple: true, ); - if (result != null) { - // Collect paths + if (result != null && result.files.isNotEmpty) { final paths = result.files .map((f) => f.path) .whereType() .toList(); if (paths.isNotEmpty) { - await repo.importFiles(paths); + // Separate audio and lyrics files + final audioPaths = paths.where((path) { + final ext = p + .extension(path) + .toLowerCase() + .replaceFirst('.', ''); + return audioExtensions.contains(ext); + }).toList(); + final lyricsPaths = paths.where((path) { + final ext = p + .extension(path) + .toLowerCase() + .replaceFirst('.', ''); + return lyricsExtensions.contains(ext); + }).toList(); + + // Import tracks if any + if (audioPaths.isNotEmpty) { + await repo.importFiles(audioPaths); + } + + // Import lyrics if any + if (lyricsPaths.isNotEmpty) { + await _batchImportLyricsFromPaths( + context, + ref, + lyricsPaths, + ); + } } } }, ), - IconButton( - icon: const Icon(Icons.lyrics_outlined), - tooltip: 'Batch Import Lyrics', - onPressed: () => _batchImportLyrics(context, ref), - ), const Gap(8), ], ), @@ -609,14 +654,12 @@ class LibraryScreen extends HookConsumerWidget { } } - Future _batchImportLyrics(BuildContext context, WidgetRef ref) async { - final result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['lrc', 'srt', 'txt'], - allowMultiple: true, - ); - - if (result == null || result.files.isEmpty) return; + Future _batchImportLyricsFromPaths( + BuildContext context, + WidgetRef ref, + List lyricsPaths, + ) async { + if (lyricsPaths.isEmpty) return; final repo = ref.read(trackRepositoryProvider.notifier); final tracks = await repo.getAllTracks(); @@ -624,12 +667,10 @@ class LibraryScreen extends HookConsumerWidget { int matched = 0; int notMatched = 0; - for (final pickedFile in result.files) { - if (pickedFile.path == null) continue; - - final file = File(pickedFile.path!); + for (final path in lyricsPaths) { + final file = File(path); final content = await file.readAsString(); - final filename = pickedFile.name; + final filename = p.basename(path); // Get basename without extension for matching final baseName = filename diff --git a/lib/ui/screens/player_screen.dart b/lib/ui/screens/player_screen.dart index 183eb82..a7b07f0 100644 --- a/lib/ui/screens/player_screen.dart +++ b/lib/ui/screens/player_screen.dart @@ -1,16 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:groovybox/data/db.dart' as db; +import 'package:groovybox/logic/lyrics_parser.dart'; +import 'package:groovybox/logic/metadata_service.dart'; +import 'package:groovybox/providers/audio_provider.dart'; +import 'package:groovybox/providers/db_provider.dart'; +import 'package:groovybox/ui/widgets/mini_player.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:media_kit/media_kit.dart'; import 'package:super_sliver_list/super_sliver_list.dart'; -import '../../providers/audio_provider.dart'; -import '../../providers/db_provider.dart'; -import '../../logic/metadata_service.dart'; -import '../../logic/lyrics_parser.dart'; -import '../../data/db.dart' as db; -import '../widgets/mini_player.dart'; - class PlayerScreen extends HookConsumerWidget { const PlayerScreen({super.key}); diff --git a/lib/ui/screens/playlist_detail_screen.dart b/lib/ui/screens/playlist_detail_screen.dart new file mode 100644 index 0000000..730fe0a --- /dev/null +++ b/lib/ui/screens/playlist_detail_screen.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:groovybox/data/db.dart'; +import 'package:groovybox/data/playlist_repository.dart'; +import 'package:groovybox/providers/audio_provider.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:media_kit/media_kit.dart' hide Track, Playlist; + +class PlaylistDetailScreen extends HookConsumerWidget { + final Playlist playlist; + + const PlaylistDetailScreen({super.key, required this.playlist}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final repo = ref.watch(playlistRepositoryProvider.notifier); + final tracksAsync = repo.watchPlaylistTracks(playlist.id); + + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: 200, + pinned: true, + flexibleSpace: FlexibleSpaceBar( + title: Text(playlist.name), + background: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.purple.withOpacity(0.8), + Colors.blue.withOpacity(0.6), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: const Center( + child: Icon( + Icons.queue_music, + size: 80, + color: Colors.white70, + ), + ), + ), + ), + ), + StreamBuilder>( + stream: tracksAsync, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ); + } + + final tracks = snapshot.data!; + if (tracks.isEmpty) { + return const SliverFillRemaining( + child: Center(child: Text('No tracks in this playlist')), + ); + } + + return SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + if (index == 0) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () { + _playPlaylist(ref, tracks); + }, + icon: const Icon(Icons.play_arrow), + label: const Text('Play All'), + ), + ), + ), + _buildTrackTile(ref, tracks, index), + ], + ); + } + return _buildTrackTile(ref, tracks, index); + }, childCount: tracks.length), + ); + }, + ), + ], + ), + ); + } + + Widget _buildTrackTile(WidgetRef ref, List tracks, int index) { + final track = tracks[index]; + return ListTile( + leading: Text( + '${index + 1}', + style: const TextStyle(color: Colors.grey, fontSize: 16), + ), + title: Text(track.title), + subtitle: Text(track.artist ?? 'Unknown Artist'), + trailing: Text(_formatDuration(track.duration)), + onTap: () { + _playPlaylist(ref, tracks, initialIndex: index); + }, + ); + } + + void _playPlaylist( + WidgetRef ref, + List tracks, { + int initialIndex = 0, + }) { + final audioHandler = ref.read(audioHandlerProvider); + final medias = tracks.map((t) => Media(t.path)).toList(); + audioHandler.openPlaylist(medias, initialIndex: initialIndex); + } + + String _formatDuration(int? durationMs) { + if (durationMs == null) return '--:--'; + final d = Duration(milliseconds: durationMs); + final minutes = d.inMinutes; + final seconds = d.inSeconds % 60; + return '$minutes:${seconds.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/ui/tabs/albums_tab.dart b/lib/ui/tabs/albums_tab.dart index fb630c9..a77df8b 100644 --- a/lib/ui/tabs/albums_tab.dart +++ b/lib/ui/tabs/albums_tab.dart @@ -1,9 +1,9 @@ import 'dart:io'; import 'package:animations/animations.dart'; import 'package:flutter/material.dart'; +import 'package:groovybox/data/playlist_repository.dart'; +import 'package:groovybox/ui/screens/album_detail_screen.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../data/playlist_repository.dart'; -import '../screens/album_detail_screen.dart'; class AlbumsTab extends HookConsumerWidget { const AlbumsTab({super.key}); @@ -15,8 +15,9 @@ class AlbumsTab extends HookConsumerWidget { return StreamBuilder>( stream: repo.watchAllAlbums(), builder: (context, snapshot) { - if (!snapshot.hasData) + if (!snapshot.hasData) { return const Center(child: CircularProgressIndicator()); + } final albums = snapshot.data!; if (albums.isEmpty) { diff --git a/lib/ui/tabs/playlists_tab.dart b/lib/ui/tabs/playlists_tab.dart index a089c5f..69a44cc 100644 --- a/lib/ui/tabs/playlists_tab.dart +++ b/lib/ui/tabs/playlists_tab.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:groovybox/data/db.dart'; +import 'package:groovybox/data/playlist_repository.dart'; +import 'package:groovybox/ui/screens/playlist_detail_screen.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../data/db.dart'; -import '../../data/playlist_repository.dart'; class PlaylistsTab extends HookConsumerWidget { const PlaylistsTab({super.key}); @@ -44,8 +45,9 @@ class PlaylistsTab extends HookConsumerWidget { body: StreamBuilder>( stream: repo.watchAllPlaylists(), builder: (context, snapshot) { - if (!snapshot.hasData) + if (!snapshot.hasData) { return const Center(child: CircularProgressIndicator()); + } final playlists = snapshot.data!; if (playlists.isEmpty) { @@ -67,9 +69,12 @@ class PlaylistsTab extends HookConsumerWidget { onPressed: () => repo.deletePlaylist(playlist.id), ), onTap: () { - // Navigate to playlist details - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Open ${playlist.name}')), + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + PlaylistDetailScreen(playlist: playlist), + ), ); }, ); diff --git a/lib/ui/widgets/mini_player.dart b/lib/ui/widgets/mini_player.dart index 78b2d29..5181dad 100644 --- a/lib/ui/widgets/mini_player.dart +++ b/lib/ui/widgets/mini_player.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; +import 'package:groovybox/logic/metadata_service.dart'; +import 'package:groovybox/providers/audio_provider.dart'; +import 'package:groovybox/ui/screens/player_screen.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:media_kit/media_kit.dart'; -import '../../providers/audio_provider.dart'; -import '../../logic/metadata_service.dart'; -import '../screens/player_screen.dart'; class MiniPlayer extends HookConsumerWidget { final bool enableTapToOpen;