diff --git a/lib/ui/screens/player_screen.dart b/lib/ui/screens/player_screen.dart index 3a461e9..183eb82 100644 --- a/lib/ui/screens/player_screen.dart +++ b/lib/ui/screens/player_screen.dart @@ -45,7 +45,7 @@ class PlayerScreen extends HookConsumerWidget { if (isMobile) { return Padding( padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top + 48, + top: MediaQuery.of(context).padding.top + 64, ), child: _MobileLayout( player: player, @@ -69,20 +69,21 @@ class PlayerScreen extends HookConsumerWidget { ), // IconButton Positioned( - top: MediaQuery.of(context).padding.top + 8, - left: 8, + top: MediaQuery.of(context).padding.top + 16, + left: 16, child: IconButton( icon: const Icon(Icons.keyboard_arrow_down), onPressed: () => Navigator.of(context).pop(), padding: EdgeInsets.zero, + iconSize: 24, ), ), // TabBar (if mobile) if (isMobile) Positioned( - top: MediaQuery.of(context).padding.top + 8, - left: 50, - right: 50, + top: MediaQuery.of(context).padding.top + 14, + left: 54, + right: 54, child: TabBar( controller: tabController, tabAlignment: TabAlignment.fill, @@ -175,49 +176,60 @@ class _DesktopLayout extends StatelessWidget { @override Widget build(BuildContext context) { - return Row( + return Stack( children: [ // Left Side: Cover + Controls - Expanded( - flex: 1, - 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, + Positioned.fill( + child: Row( + children: [ + Expanded( + 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), - ], + ), ), - ), + Expanded(child: const SizedBox.shrink()), + ], ), ), - // Right Side: Lyrics - Expanded( - flex: 1, + // Overlaid Lyrics on the right + Positioned( + right: 0, + top: 0, + bottom: 0, + width: MediaQuery.sizeOf(context).width * 0.6, child: Padding( - padding: EdgeInsets.all(32.0), + padding: const EdgeInsets.symmetric(horizontal: 32), child: _PlayerLyrics(trackPath: trackPath, player: player), ), ), @@ -347,8 +359,14 @@ class _TimedLyricsView extends HookWidget { @override Widget build(BuildContext context) { + final isDesktop = MediaQuery.sizeOf(context).width > 640; + final listController = useMemoized(() => ListController(), []); final scrollController = useScrollController(); + final wheelScrollController = useMemoized( + () => FixedExtentScrollController(), + [], + ); final previousIndex = useState(-1); return StreamBuilder( @@ -372,16 +390,86 @@ class _TimedLyricsView extends HookWidget { 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, - ); + if (isDesktop) { + if (wheelScrollController.hasClients) { + wheelScrollController.animateToItem( + currentIndex, + duration: const Duration(milliseconds: 400), + curve: Curves.easeOutCubic, + ); + } + } else { + listController.animateToItem( + index: currentIndex, + scrollController: scrollController, + alignment: 0.5, + duration: (_) => const Duration(milliseconds: 300), + curve: (_) => Curves.easeOutCubic, + ); + } }); } + if (isDesktop) { + return ListWheelScrollView.useDelegate( + controller: wheelScrollController, + itemExtent: 50, + perspective: 0.002, + offAxisFraction: 1.5, + squeeze: 1.0, + diameterRatio: 2, + physics: const FixedExtentScrollPhysics(), + childDelegate: ListWheelChildBuilderDelegate( + childCount: lyrics.lines.length, + builder: (context, index) { + final line = lyrics.lines[index]; + final isActive = index == currentIndex; + + return Align( + alignment: Alignment.centerRight, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.4, + ), + child: InkWell( + onTap: () { + if (line.timeMs != null) { + player.seek(Duration(milliseconds: line.timeMs!)); + } + }, + child: Container( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 32), + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 200), + style: Theme.of(context).textTheme.bodyLarge! + .copyWith( + fontSize: isActive ? 18 : 16, + fontWeight: isActive + ? FontWeight.bold + : FontWeight.normal, + color: isActive + ? Theme.of(context).colorScheme.primary + : Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.7), + ), + textAlign: TextAlign.left, + child: Text( + line.text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ), + ); + }, + ), + ); + } + return SuperListView.builder( padding: EdgeInsets.only( top: 0.25 * MediaQuery.sizeOf(context).height, @@ -414,7 +502,7 @@ class _TimedLyricsView extends HookWidget { ? Theme.of(context).colorScheme.primary : Theme.of( context, - ).colorScheme.onSurface.withValues(alpha: 0.7), + ).colorScheme.onSurface.withOpacity(0.7), ), textAlign: TextAlign.center, child: Text(line.text, textAlign: TextAlign.center), @@ -459,7 +547,7 @@ class _PlayerControls extends HookWidget { overflow: TextOverflow.ellipsis, ), loading: () => const SizedBox(height: 32), - error: (_, __) => Text(Uri.parse(media.uri).pathSegments.last), + error: (_, _) => Text(Uri.parse(media.uri).pathSegments.last), ), const SizedBox(height: 8), metadataAsync.when(