💄 Mixed import button

This commit is contained in:
2025-12-15 00:40:07 +08:00
parent 82226ede2f
commit 8bae372215
11 changed files with 231 additions and 58 deletions

View File

@@ -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';

View File

@@ -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';

View File

@@ -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)

View File

@@ -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';

View File

@@ -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;

View File

@@ -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<String> audioExtensions = [
'mp3',
'm4a',
'wav',
'flac',
'aac',
'ogg',
'wma',
'm4p',
'aiff',
'au',
'dss',
];
static const List<String> lyricsExtensions = ['lrc', 'srt', 'txt'];
static const List<String> 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<String>()
.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<void> _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<void> _batchImportLyricsFromPaths(
BuildContext context,
WidgetRef ref,
List<String> 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

View File

@@ -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});

View File

@@ -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<List<Track>>(
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<Track> 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<Track> 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')}';
}
}

View File

@@ -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<List<AlbumData>>(
stream: repo.watchAllAlbums(),
builder: (context, snapshot) {
if (!snapshot.hasData)
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final albums = snapshot.data!;
if (albums.isEmpty) {

View File

@@ -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<List<Playlist>>(
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),
),
);
},
);

View File

@@ -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;