Files
GroovyBox/lib/ui/screens/player_screen.dart

506 lines
16 KiB
Dart

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});
@override
Widget build(BuildContext context, WidgetRef ref) {
final audioHandler = ref.watch(audioHandlerProvider);
final player = audioHandler.player;
return Scaffold(
appBar: AppBar(
title: const Text('Now Playing'),
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.keyboard_arrow_down),
onPressed: () => Navigator.of(context).pop(),
),
),
body: StreamBuilder<Playlist>(
stream: player.stream.playlist,
initialData: player.state.playlist,
builder: (context, snapshot) {
final index = snapshot.data?.index ?? 0;
final medias = snapshot.data?.medias ?? [];
if (medias.isEmpty || index < 0 || index >= medias.length) {
return const Center(child: Text('No media selected'));
}
final media = medias[index];
final path = Uri.decodeFull(Uri.parse(media.uri).path);
final metadataAsync = ref.watch(trackMetadataProvider(path));
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;
final seconds = d.inSeconds % 60;
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
}