✨ Better now playing and detail of album
This commit is contained in:
@@ -19,6 +19,10 @@ class AudioHandler {
|
||||
await _player.open(Media(path));
|
||||
}
|
||||
|
||||
Future<void> openPlaylist(List<Media> medias, {int initialIndex = 0}) async {
|
||||
await _player.open(Playlist(medias, index: initialIndex), play: true);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_player.dispose();
|
||||
}
|
||||
|
||||
116
lib/ui/screens/album_detail_screen.dart
Normal file
116
lib/ui/screens/album_detail_screen.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.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;
|
||||
|
||||
const AlbumDetailScreen({super.key, required this.album});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final repo = ref.watch(playlistRepositoryProvider.notifier);
|
||||
final tracksAsync = repo.watchAlbumTracks(album.album);
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 300,
|
||||
pinned: true,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
title: Text(album.album),
|
||||
background: album.artUri != null
|
||||
? Image.file(File(album.artUri!), fit: BoxFit.cover)
|
||||
: Container(
|
||||
color: Colors.grey[800],
|
||||
child: const Icon(
|
||||
Icons.album,
|
||||
size: 100,
|
||||
color: Colors.white54,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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 album')),
|
||||
);
|
||||
}
|
||||
|
||||
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: () {
|
||||
_playAlbum(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(_formatDuration(track.duration)),
|
||||
onTap: () {
|
||||
_playAlbum(ref, tracks, initialIndex: index);
|
||||
},
|
||||
trailing: const Icon(Icons.play_circle_outline),
|
||||
);
|
||||
}
|
||||
|
||||
void _playAlbum(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')}';
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
||||
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:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../data/track_repository.dart';
|
||||
@@ -75,6 +76,7 @@ class LibraryScreen extends HookConsumerWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
)
|
||||
: AppBar(
|
||||
@@ -107,6 +109,7 @@ class LibraryScreen extends HookConsumerWidget {
|
||||
}
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
body: TabBarView(
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:flutter/material.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 '../widgets/mini_player.dart';
|
||||
|
||||
class PlayerScreen extends HookConsumerWidget {
|
||||
const PlayerScreen({super.key});
|
||||
@@ -36,201 +38,464 @@ class PlayerScreen extends HookConsumerWidget {
|
||||
|
||||
final metadataAsync = ref.watch(trackMetadataProvider(path));
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Cover Art
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: metadataAsync.when(
|
||||
data: (meta) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[800],
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
image: meta.artBytes != null
|
||||
? DecorationImage(
|
||||
image: MemoryImage(meta.artBytes!),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: meta.artBytes == null
|
||||
? const Center(
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
size: 80,
|
||||
color: Colors.white54,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[800],
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.error_outline,
|
||||
size: 80,
|
||||
color: Colors.white54,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Track Info
|
||||
Column(
|
||||
children: [
|
||||
metadataAsync.when(
|
||||
data: (meta) => Text(
|
||||
meta.title ?? Uri.parse(media.uri).pathSegments.last,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
loading: () => const SizedBox(height: 32),
|
||||
error: (_, __) =>
|
||||
Text(Uri.parse(media.uri).pathSegments.last),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
metadataAsync.when(
|
||||
data: (meta) => Text(
|
||||
meta.artist ?? 'Unknown Artist',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
loading: () => const SizedBox(height: 24),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Lyrics (Placeholder)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'No Lyrics Available',
|
||||
style: TextStyle(fontStyle: FontStyle.italic),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Progress Bar
|
||||
StreamBuilder<Duration>(
|
||||
stream: player.stream.position,
|
||||
builder: (context, snapshot) {
|
||||
final position = snapshot.data ?? Duration.zero;
|
||||
|
||||
return StreamBuilder<Duration>(
|
||||
stream: player.stream.duration,
|
||||
builder: (context, durationSnapshot) {
|
||||
final totalDuration =
|
||||
durationSnapshot.data ?? Duration.zero;
|
||||
final max = totalDuration.inSeconds.toDouble();
|
||||
final value = position.inSeconds.toDouble().clamp(
|
||||
0.0,
|
||||
max > 0 ? max : 0.0,
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Slider(
|
||||
value: value,
|
||||
min: 0,
|
||||
max: max > 0 ? max : 1.0,
|
||||
onChanged: (val) {
|
||||
player.seek(Duration(seconds: val.toInt()));
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24.0,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(_formatDuration(position)),
|
||||
Text(_formatDuration(totalDuration)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Controls
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.skip_previous, size: 32),
|
||||
onPressed: player.previous,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
StreamBuilder<bool>(
|
||||
stream: player.stream.playing,
|
||||
builder: (context, snapshot) {
|
||||
final playing = snapshot.data ?? false;
|
||||
return IconButton.filled(
|
||||
icon: Icon(
|
||||
playing ? Icons.pause : Icons.play_arrow,
|
||||
size: 48,
|
||||
),
|
||||
onPressed: playing ? player.pause : player.play,
|
||||
iconSize: 48,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.skip_next, size: 32),
|
||||
onPressed: player.next,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
],
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth < 600) {
|
||||
return _MobileLayout(
|
||||
player: player,
|
||||
metadataAsync: metadataAsync,
|
||||
media: media,
|
||||
);
|
||||
} else {
|
||||
return _DesktopLayout(
|
||||
player: player,
|
||||
metadataAsync: metadataAsync,
|
||||
media: media,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MobileLayout extends StatelessWidget {
|
||||
final Player player;
|
||||
final AsyncValue<TrackMetadata> metadataAsync;
|
||||
final Media media;
|
||||
|
||||
const _MobileLayout({
|
||||
required this.player,
|
||||
required this.metadataAsync,
|
||||
required this.media,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Column(
|
||||
children: [
|
||||
const TabBar(
|
||||
tabs: [
|
||||
Tab(text: 'Cover'),
|
||||
Tab(text: 'Lyrics'),
|
||||
],
|
||||
dividerColor: Colors.transparent,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
overlayColor: WidgetStatePropertyAll(Colors.transparent),
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
// Cover Art Tab with Full Controls
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Center(
|
||||
child: _PlayerCoverArt(metadataAsync: metadataAsync),
|
||||
),
|
||||
),
|
||||
),
|
||||
_PlayerControls(
|
||||
player: player,
|
||||
metadataAsync: metadataAsync,
|
||||
media: media,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
// Lyrics Tab with Mini Player
|
||||
Column(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: _PlayerLyrics(),
|
||||
),
|
||||
),
|
||||
MiniPlayer(enableTapToOpen: false),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Tab Indicators (Overlay style or bottom? Standard is usually separate or top. Keeping bottom for consistency with previous, but maybe cleaner at top? User didn't specify position, just content. Let's keep indicators at bottom of screen but actually, if controls are in tabs, indicators might overlap? Moving indicators to top standard position or just removing them if swiping is enough? Let's keep them at the top of the content area for clarity, or overlay. Actually, previous layout had indicators below TabBarView but above controls. Now controls are IN TabBarView. Let's put TabBar at the TOP or BOTTOM of the screen. Top is standard Android/iOS for sub-views. Let's try TOP.)
|
||||
// Actually, let's put TabBar at the very bottom, or top. Let's stick to top as standard.)
|
||||
// Wait, the previous code had them between tabs and controls.
|
||||
// Let's place TabBar at the top of the screen (below AppBar) for this layout.
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DesktopLayout extends StatelessWidget {
|
||||
final Player player;
|
||||
final AsyncValue<TrackMetadata> metadataAsync;
|
||||
final Media media;
|
||||
|
||||
const _DesktopLayout({
|
||||
required this.player,
|
||||
required this.metadataAsync,
|
||||
required this.media,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
// Left Side: Cover + Controls
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: _PlayerCoverArt(metadataAsync: metadataAsync),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_PlayerControls(
|
||||
player: player,
|
||||
metadataAsync: metadataAsync,
|
||||
media: media,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Right Side: Lyrics
|
||||
const Expanded(
|
||||
flex: 1,
|
||||
child: Padding(padding: EdgeInsets.all(32.0), child: _PlayerLyrics()),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayerCoverArt extends StatelessWidget {
|
||||
final AsyncValue<TrackMetadata> metadataAsync;
|
||||
|
||||
const _PlayerCoverArt({required this.metadataAsync});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return metadataAsync.when(
|
||||
data: (meta) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[800],
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
image: meta.artBytes != null
|
||||
? DecorationImage(
|
||||
image: MemoryImage(meta.artBytes!),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: meta.artBytes == null
|
||||
? const Center(
|
||||
child: Icon(Icons.music_note, size: 80, color: Colors.white54),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[800],
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(Icons.error_outline, size: 80, color: Colors.white54),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayerLyrics extends StatelessWidget {
|
||||
const _PlayerLyrics();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'No Lyrics Available',
|
||||
style: TextStyle(fontStyle: FontStyle.italic),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayerControls extends HookWidget {
|
||||
final Player player;
|
||||
final AsyncValue<TrackMetadata> metadataAsync;
|
||||
final Media media;
|
||||
|
||||
const _PlayerControls({
|
||||
required this.player,
|
||||
required this.metadataAsync,
|
||||
required this.media,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDragging = useState(false);
|
||||
final dragValue = useState(0.0);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Title & Artist
|
||||
Column(
|
||||
children: [
|
||||
metadataAsync.when(
|
||||
data: (meta) => Text(
|
||||
meta.title ?? Uri.parse(media.uri).pathSegments.last,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
loading: () => const SizedBox(height: 32),
|
||||
error: (_, __) => Text(Uri.parse(media.uri).pathSegments.last),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
metadataAsync.when(
|
||||
data: (meta) => Text(
|
||||
meta.artist ?? 'Unknown Artist',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
loading: () => const SizedBox(height: 24),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Progress Bar
|
||||
StreamBuilder<Duration>(
|
||||
stream: player.stream.position,
|
||||
initialData: player.state.position,
|
||||
builder: (context, snapshot) {
|
||||
final position = snapshot.data ?? Duration.zero;
|
||||
|
||||
return StreamBuilder<Duration>(
|
||||
stream: player.stream.duration,
|
||||
initialData: player.state.duration,
|
||||
builder: (context, durationSnapshot) {
|
||||
final totalDuration = durationSnapshot.data ?? Duration.zero;
|
||||
final max = totalDuration.inMilliseconds.toDouble();
|
||||
final positionValue = position.inMilliseconds.toDouble().clamp(
|
||||
0.0,
|
||||
max > 0 ? max : 0.0,
|
||||
);
|
||||
|
||||
final currentValue = isDragging.value
|
||||
? dragValue.value
|
||||
: positionValue;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Slider(
|
||||
value: currentValue,
|
||||
min: 0,
|
||||
max: max > 0 ? max : 1.0,
|
||||
onChanged: (val) {
|
||||
isDragging.value = true;
|
||||
dragValue.value = val;
|
||||
},
|
||||
onChangeEnd: (val) {
|
||||
isDragging.value = false;
|
||||
player.seek(Duration(milliseconds: val.toInt()));
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration(
|
||||
Duration(milliseconds: currentValue.toInt()),
|
||||
),
|
||||
),
|
||||
Text(_formatDuration(totalDuration)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Media Controls
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Shuffle
|
||||
IconButton(
|
||||
icon: StreamBuilder<bool>(
|
||||
stream: player.stream.shuffle,
|
||||
builder: (context, snapshot) {
|
||||
final shuffle = snapshot.data ?? false;
|
||||
return Icon(
|
||||
Icons.shuffle,
|
||||
color: shuffle
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).disabledColor,
|
||||
);
|
||||
},
|
||||
),
|
||||
onPressed: () {
|
||||
player.setShuffle(!player.state.shuffle);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Previous
|
||||
IconButton(
|
||||
icon: const Icon(Icons.skip_previous, size: 32),
|
||||
onPressed: player.previous,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Play/Pause
|
||||
StreamBuilder<bool>(
|
||||
stream: player.stream.playing,
|
||||
builder: (context, snapshot) {
|
||||
final playing = snapshot.data ?? false;
|
||||
return IconButton.filled(
|
||||
icon: Icon(
|
||||
playing ? Icons.pause : Icons.play_arrow,
|
||||
size: 48,
|
||||
),
|
||||
onPressed: playing ? player.pause : player.play,
|
||||
iconSize: 48,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Next
|
||||
IconButton(
|
||||
icon: const Icon(Icons.skip_next, size: 32),
|
||||
onPressed: player.next,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Loop Mode
|
||||
IconButton(
|
||||
icon: StreamBuilder<PlaylistMode>(
|
||||
stream: player.stream.playlistMode,
|
||||
builder: (context, snapshot) {
|
||||
final mode = snapshot.data ?? PlaylistMode.none;
|
||||
IconData icon;
|
||||
Color? color;
|
||||
switch (mode) {
|
||||
case PlaylistMode.none:
|
||||
icon = Icons.repeat;
|
||||
color = Theme.of(context).disabledColor;
|
||||
break;
|
||||
case PlaylistMode.single:
|
||||
icon = Icons.repeat_one;
|
||||
color = Theme.of(context).colorScheme.primary;
|
||||
break;
|
||||
case PlaylistMode.loop:
|
||||
icon = Icons.repeat;
|
||||
color = Theme.of(context).colorScheme.primary;
|
||||
break;
|
||||
}
|
||||
return Icon(icon, color: color);
|
||||
},
|
||||
),
|
||||
onPressed: () {
|
||||
final mode = player.state.playlistMode;
|
||||
switch (mode) {
|
||||
case PlaylistMode.none:
|
||||
player.setPlaylistMode(PlaylistMode.single);
|
||||
break;
|
||||
case PlaylistMode.single:
|
||||
player.setPlaylistMode(PlaylistMode.loop);
|
||||
break;
|
||||
case PlaylistMode.loop:
|
||||
player.setPlaylistMode(PlaylistMode.none);
|
||||
break;
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Volume Slider
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||
child: StreamBuilder<double>(
|
||||
stream: player.stream.volume,
|
||||
builder: (context, snapshot) {
|
||||
final volume = snapshot.data ?? 100.0;
|
||||
return Row(
|
||||
children: [
|
||||
Icon(
|
||||
volume == 0
|
||||
? Icons.volume_off
|
||||
: volume < 50
|
||||
? Icons.volume_down
|
||||
: Icons.volume_up,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: volume,
|
||||
min: 0,
|
||||
max: 100,
|
||||
divisions: 100,
|
||||
label: volume.round().toString(),
|
||||
onChanged: (value) {
|
||||
player.setVolume(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration d) {
|
||||
final minutes = d.inMinutes;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'dart:io';
|
||||
import 'package:animations/animations.dart';
|
||||
import 'package:flutter/material.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});
|
||||
@@ -32,17 +34,17 @@ class AlbumsTab extends HookConsumerWidget {
|
||||
itemCount: albums.length,
|
||||
itemBuilder: (context, index) {
|
||||
final album = albums[index];
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// Navigate to Album details (list of tracks)
|
||||
// For now just show snackbar or simple push
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Open ${album.album}')),
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
return OpenContainer(
|
||||
openBuilder: (context, action) {
|
||||
return AlbumDetailScreen(album: album);
|
||||
},
|
||||
closedColor: Theme.of(context).cardColor,
|
||||
closedShape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
closedElevation: 0,
|
||||
closedBuilder: (context, action) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -78,8 +80,8 @@ class AlbumsTab extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import 'package:flutter/material.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 {
|
||||
const MiniPlayer({super.key});
|
||||
final bool enableTapToOpen;
|
||||
|
||||
const MiniPlayer({super.key, this.enableTapToOpen = true});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final audioHandler = ref.watch(audioHandlerProvider);
|
||||
final player = audioHandler.player;
|
||||
|
||||
final isDragging = useState(false);
|
||||
final dragValue = useState(0.0);
|
||||
|
||||
return StreamBuilder<Playlist>(
|
||||
stream: player.stream.playlist,
|
||||
initialData: player.state.playlist,
|
||||
@@ -29,95 +35,163 @@ class MiniPlayer extends HookConsumerWidget {
|
||||
|
||||
final metadataAsync = ref.watch(trackMetadataProvider(filePath));
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => const PlayerScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
height: 64,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
Widget content = Container(
|
||||
height: 80, // Increased height for slider
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Cover Art (Small)
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: metadataAsync.when(
|
||||
data: (meta) => meta.artBytes != null
|
||||
? Image.memory(meta.artBytes!, fit: BoxFit.cover)
|
||||
: Container(
|
||||
color: Colors.grey[800],
|
||||
child: const Icon(
|
||||
Icons.music_note,
|
||||
color: Colors.white54,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Progress Bar
|
||||
SizedBox(
|
||||
height: 4,
|
||||
width: double.infinity,
|
||||
child: StreamBuilder<Duration>(
|
||||
stream: player.stream.position,
|
||||
initialData: player.state.position,
|
||||
builder: (context, snapshot) {
|
||||
final position = snapshot.data ?? Duration.zero;
|
||||
return StreamBuilder<Duration>(
|
||||
stream: player.stream.duration,
|
||||
initialData: player.state.duration,
|
||||
builder: (context, durationSnapshot) {
|
||||
final total = durationSnapshot.data ?? Duration.zero;
|
||||
final max = total.inMilliseconds.toDouble();
|
||||
final positionValue = position.inMilliseconds
|
||||
.toDouble()
|
||||
.clamp(0.0, max > 0 ? max : 0.0);
|
||||
|
||||
final currentValue = isDragging.value
|
||||
? dragValue.value
|
||||
: positionValue;
|
||||
|
||||
return SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
// Let's keep a small thumb or make it visible on hover/touch.
|
||||
// Standard Slider has a thumb.
|
||||
trackHeight: 2,
|
||||
overlayShape: SliderComponentShape.noOverlay,
|
||||
thumbShape: const RoundSliderThumbShape(
|
||||
enabledThumbRadius: 6,
|
||||
),
|
||||
),
|
||||
loading: () => Container(color: Colors.grey[800]),
|
||||
error: (_, __) => Container(color: Colors.grey[800]),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
metadataAsync.when(
|
||||
data: (meta) => Text(
|
||||
meta.title ??
|
||||
Uri.parse(media.uri).pathSegments.last,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
child: Slider(
|
||||
value: currentValue,
|
||||
min: 0,
|
||||
max: max > 0 ? max : 1.0,
|
||||
onChanged: (val) {
|
||||
isDragging.value = true;
|
||||
dragValue.value = val;
|
||||
},
|
||||
onChangeEnd: (val) {
|
||||
isDragging.value = false;
|
||||
player.seek(Duration(milliseconds: val.toInt()));
|
||||
},
|
||||
),
|
||||
loading: () => const Text('Loading...'),
|
||||
error: (_, __) =>
|
||||
Text(Uri.parse(media.uri).pathSegments.last),
|
||||
),
|
||||
metadataAsync.when(
|
||||
data: (meta) => Text(
|
||||
meta.artist ?? 'Unknown Artist',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
StreamBuilder<bool>(
|
||||
stream: player.stream.playing,
|
||||
builder: (context, snapshot) {
|
||||
final playing = snapshot.data ?? false;
|
||||
return IconButton(
|
||||
icon: Icon(playing ? Icons.pause : Icons.play_arrow),
|
||||
onPressed: playing ? player.pause : player.play,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
// Cover Art (Small)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: metadataAsync.when(
|
||||
data: (meta) => meta.artBytes != null
|
||||
? Image.memory(meta.artBytes!, fit: BoxFit.cover)
|
||||
: Container(
|
||||
color: Colors.grey[800],
|
||||
child: const Icon(
|
||||
Icons.music_note,
|
||||
color: Colors.white54,
|
||||
),
|
||||
),
|
||||
loading: () => Container(color: Colors.grey[800]),
|
||||
error: (_, __) => Container(color: Colors.grey[800]),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
metadataAsync.when(
|
||||
data: (meta) => Text(
|
||||
meta.title ??
|
||||
Uri.parse(media.uri).pathSegments.last,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
loading: () => const Text('Loading...'),
|
||||
error: (_, __) =>
|
||||
Text(Uri.parse(media.uri).pathSegments.last),
|
||||
),
|
||||
metadataAsync.when(
|
||||
data: (meta) => Text(
|
||||
meta.artist ?? 'Unknown Artist',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
StreamBuilder<bool>(
|
||||
stream: player.stream.playing,
|
||||
builder: (context, snapshot) {
|
||||
final playing = snapshot.data ?? false;
|
||||
return IconButton(
|
||||
icon: Icon(playing ? Icons.pause : Icons.play_arrow),
|
||||
onPressed: playing ? player.pause : player.play,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (enableTapToOpen) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => const PlayerScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: content,
|
||||
);
|
||||
} else {
|
||||
return content;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
16
pubspec.lock
16
pubspec.lock
@@ -33,6 +33,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.10"
|
||||
animations:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: animations
|
||||
sha256: "18938cefd7dcc04e1ecac0db78973761a01e4bc2d6bfae0cfa596bfeac9e96ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -384,6 +392,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
gap:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: gap
|
||||
sha256: f19387d4e32f849394758b91377f9153a1b41d79513ef7668c088c77dbc6955d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -46,6 +46,8 @@ dependencies:
|
||||
riverpod_annotation: ^3.0.3
|
||||
file_picker: ^10.3.7
|
||||
flutter_media_metadata: ^1.0.0+1
|
||||
animations: ^2.1.1
|
||||
gap: ^3.0.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user