💄 Better player UI
This commit is contained in:
@@ -2,9 +2,13 @@ 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 '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 {
|
||||
@@ -15,47 +19,84 @@ class PlayerScreen extends HookConsumerWidget {
|
||||
final audioHandler = ref.watch(audioHandlerProvider);
|
||||
final player = audioHandler.player;
|
||||
|
||||
final tabController = useTabController(initialLength: 2);
|
||||
final isMobile = MediaQuery.sizeOf(context).width <= 640;
|
||||
|
||||
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,
|
||||
);
|
||||
body: Stack(
|
||||
children: [
|
||||
// Main content (StreamBuilder)
|
||||
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 Builder(
|
||||
builder: (context) {
|
||||
if (isMobile) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top + 48,
|
||||
),
|
||||
child: _MobileLayout(
|
||||
player: player,
|
||||
tabController: tabController,
|
||||
metadataAsync: metadataAsync,
|
||||
media: media,
|
||||
trackPath: path,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return _DesktopLayout(
|
||||
player: player,
|
||||
metadataAsync: metadataAsync,
|
||||
media: media,
|
||||
trackPath: path,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
// IconButton
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 8,
|
||||
left: 8,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.keyboard_arrow_down),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
// TabBar (if mobile)
|
||||
if (isMobile)
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 8,
|
||||
left: 50,
|
||||
right: 50,
|
||||
child: TabBar(
|
||||
controller: tabController,
|
||||
tabAlignment: TabAlignment.fill,
|
||||
tabs: const [
|
||||
Tab(text: 'Cover'),
|
||||
Tab(text: 'Lyrics'),
|
||||
],
|
||||
dividerHeight: 0,
|
||||
indicatorColor: Colors.transparent,
|
||||
overlayColor: WidgetStatePropertyAll(Colors.transparent),
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -63,73 +104,58 @@ class PlayerScreen extends HookConsumerWidget {
|
||||
|
||||
class _MobileLayout extends StatelessWidget {
|
||||
final Player player;
|
||||
final TabController tabController;
|
||||
final AsyncValue<TrackMetadata> metadataAsync;
|
||||
final Media media;
|
||||
final String trackPath;
|
||||
|
||||
const _MobileLayout({
|
||||
required this.player,
|
||||
required this.tabController,
|
||||
required this.metadataAsync,
|
||||
required this.media,
|
||||
required this.trackPath,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Column(
|
||||
children: [
|
||||
const TabBar(
|
||||
tabs: [
|
||||
Tab(text: 'Cover'),
|
||||
Tab(text: 'Lyrics'),
|
||||
return TabBarView(
|
||||
controller: tabController,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: 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),
|
||||
],
|
||||
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),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
// Lyrics Tab with Mini Player
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: _PlayerLyrics(trackPath: trackPath, player: player),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 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.
|
||||
],
|
||||
),
|
||||
MiniPlayer(enableTapToOpen: false),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -138,11 +164,13 @@ class _DesktopLayout extends StatelessWidget {
|
||||
final Player player;
|
||||
final AsyncValue<TrackMetadata> metadataAsync;
|
||||
final Media media;
|
||||
final String trackPath;
|
||||
|
||||
const _DesktopLayout({
|
||||
required this.player,
|
||||
required this.metadataAsync,
|
||||
required this.media,
|
||||
required this.trackPath,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -152,36 +180,46 @@ class _DesktopLayout extends StatelessWidget {
|
||||
// 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),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
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),
|
||||
],
|
||||
),
|
||||
_PlayerControls(
|
||||
player: player,
|
||||
metadataAsync: metadataAsync,
|
||||
media: media,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Right Side: Lyrics
|
||||
const Expanded(
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Padding(padding: EdgeInsets.all(32.0), child: _PlayerLyrics()),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32.0),
|
||||
child: _PlayerLyrics(trackPath: trackPath, player: player),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -221,7 +259,7 @@ class _PlayerCoverArt extends StatelessWidget {
|
||||
: null,
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) => Container(
|
||||
error: (_, _) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[800],
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
@@ -234,24 +272,158 @@ class _PlayerCoverArt extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayerLyrics extends StatelessWidget {
|
||||
const _PlayerLyrics();
|
||||
class _PlayerLyrics extends HookConsumerWidget {
|
||||
final String? trackPath;
|
||||
final Player player;
|
||||
|
||||
const _PlayerLyrics({this.trackPath, required this.player});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Watch for track data (including lyrics) by path
|
||||
final trackAsync = trackPath != null
|
||||
? ref.watch(_trackByPathProvider(trackPath!))
|
||||
: const AsyncValue<db.Track?>.data(null);
|
||||
|
||||
return trackAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('Error: $e')),
|
||||
data: (track) {
|
||||
if (track == null || track.lyrics == null) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'No Lyrics Available',
|
||||
style: TextStyle(fontStyle: FontStyle.italic),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final lyricsData = LyricsData.fromJsonString(track.lyrics!);
|
||||
|
||||
if (lyricsData.type == 'timed') {
|
||||
return _TimedLyricsView(lyrics: lyricsData, player: player);
|
||||
} else {
|
||||
// Plain text lyrics
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: lyricsData.lines.length,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Text(
|
||||
lyricsData.lines[index].text,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return Center(child: Text('Error parsing lyrics: $e'));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Provider to fetch a single track by path
|
||||
final _trackByPathProvider = FutureProvider.family<db.Track?, String>((
|
||||
ref,
|
||||
trackPath,
|
||||
) async {
|
||||
final database = ref.watch(databaseProvider);
|
||||
return (database.select(
|
||||
database.tracks,
|
||||
)..where((t) => t.path.equals(trackPath))).getSingleOrNull();
|
||||
});
|
||||
|
||||
class _TimedLyricsView extends HookWidget {
|
||||
final LyricsData lyrics;
|
||||
final Player player;
|
||||
|
||||
const _TimedLyricsView({required this.lyrics, required this.player});
|
||||
|
||||
@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),
|
||||
),
|
||||
),
|
||||
final listController = useMemoized(() => ListController(), []);
|
||||
final scrollController = useScrollController();
|
||||
final previousIndex = useState(-1);
|
||||
|
||||
return StreamBuilder<Duration>(
|
||||
stream: player.stream.position,
|
||||
initialData: player.state.position,
|
||||
builder: (context, snapshot) {
|
||||
final position = snapshot.data ?? Duration.zero;
|
||||
final positionMs = position.inMilliseconds;
|
||||
|
||||
// Find current line index
|
||||
int currentIndex = 0;
|
||||
for (int i = 0; i < lyrics.lines.length; i++) {
|
||||
if ((lyrics.lines[i].timeMs ?? 0) <= positionMs) {
|
||||
currentIndex = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll when current line changes
|
||||
if (currentIndex != previousIndex.value) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
previousIndex.value = currentIndex;
|
||||
listController.animateToItem(
|
||||
index: currentIndex,
|
||||
scrollController: scrollController,
|
||||
alignment: 0.5,
|
||||
duration: (_) => const Duration(milliseconds: 300),
|
||||
curve: (_) => Curves.easeOutCubic,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return SuperListView.builder(
|
||||
padding: EdgeInsets.only(
|
||||
top: 0.25 * MediaQuery.sizeOf(context).height,
|
||||
bottom: 0.25 * MediaQuery.sizeOf(context).height,
|
||||
),
|
||||
listController: listController,
|
||||
controller: scrollController,
|
||||
itemCount: lyrics.lines.length,
|
||||
itemBuilder: (context, index) {
|
||||
final line = lyrics.lines[index];
|
||||
final isActive = index == currentIndex;
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
if (line.timeMs != null) {
|
||||
player.seek(Duration(milliseconds: line.timeMs!));
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
horizontal: 16,
|
||||
),
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
|
||||
fontSize: isActive ? 20 : 16,
|
||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||
color: isActive
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
child: Text(line.text, textAlign: TextAlign.center),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user