💄 Mixed import button
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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});
|
||||
|
||||
|
||||
128
lib/ui/screens/playlist_detail_screen.dart
Normal file
128
lib/ui/screens/playlist_detail_screen.dart
Normal 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')}';
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user