refactor: alternative track sheet to use shadcn

This commit is contained in:
Kingkor Roy Tirtho 2025-01-03 23:10:26 +06:00
parent f96b5eae97
commit 30e03786bf
3 changed files with 378 additions and 343 deletions

View File

@ -98,223 +98,229 @@ class PlayerView extends HookConsumerWidget {
onPopInvoked: (didPop) async { onPopInvoked: (didPop) async {
await panelController.close(); await panelController.close();
}, },
child: Scaffold( child: SurfaceCard(
headers: [ borderWidth: 0,
SafeArea( surfaceOpacity: 0.9,
child: TitleBar( padding: EdgeInsets.zero,
surfaceOpacity: 0, child: Scaffold(
surfaceBlur: 0, backgroundColor: Colors.transparent,
leading: [ headers: [
IconButton.ghost( SafeArea(
icon: const Icon(SpotubeIcons.angleDown, size: 18), child: TitleBar(
onPressed: panelController.close, surfaceOpacity: 0,
) surfaceBlur: 0,
], leading: [
trailing: [ IconButton.ghost(
if (currentTrack is YoutubeSourcedTrack) icon: const Icon(SpotubeIcons.angleDown, size: 18),
TextButton( onPressed: panelController.close,
leading: Assets.logos.songlinkTransparent.image( )
width: 20, ],
height: 20, trailing: [
color: theme.colorScheme.foreground, if (currentTrack is YoutubeSourcedTrack)
), TextButton(
onPressed: () { leading: Assets.logos.songlinkTransparent.image(
final url = "https://song.link/s/${currentTrack.id}"; width: 20,
height: 20,
color: theme.colorScheme.foreground,
),
onPressed: () {
final url = "https://song.link/s/${currentTrack.id}";
launchUrlString(url); launchUrlString(url);
}, },
child: Text(context.l10n.song_link), child: Text(context.l10n.song_link),
), ),
Tooltip( Tooltip(
tooltip: TooltipContainer( tooltip: TooltipContainer(
child: Text(context.l10n.details), child: Text(context.l10n.details),
), ),
child: IconButton.ghost( child: IconButton.ghost(
icon: const Icon(SpotubeIcons.info, size: 18), icon: const Icon(SpotubeIcons.info, size: 18),
onPressed: currentTrack == null onPressed: currentTrack == null
? null ? null
: () { : () {
showDialog( showDialog(
context: context, context: context,
builder: (context) { builder: (context) {
return TrackDetailsDialog( return TrackDetailsDialog(
track: currentTrack, track: currentTrack,
); );
}); });
}, },
), ),
) )
], ],
),
), ),
), ],
], child: SingleChildScrollView(
child: SingleChildScrollView( controller: scrollController,
controller: scrollController, child: Padding(
child: Padding( padding: const EdgeInsets.all(8.0),
padding: const EdgeInsets.all(8.0), child: Column(
child: Column( children: [
children: [ Container(
Container( margin: const EdgeInsets.all(8),
margin: const EdgeInsets.all(8), constraints:
constraints: const BoxConstraints(maxHeight: 300, maxWidth: 300),
const BoxConstraints(maxHeight: 300, maxWidth: 300), decoration: BoxDecoration(
decoration: BoxDecoration( borderRadius: BorderRadius.circular(20),
borderRadius: BorderRadius.circular(20), boxShadow: [
boxShadow: [ BoxShadow(
BoxShadow( color: Colors.black.withAlpha(100),
color: Colors.black.withAlpha(100), spreadRadius: 2,
spreadRadius: 2, blurRadius: 10,
blurRadius: 10, offset: Offset.zero,
offset: Offset.zero, ),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: UniversalImage(
path: albumArt,
placeholder: Assets.albumPlaceholder.path,
fit: BoxFit.cover,
), ),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: UniversalImage(
path: albumArt,
placeholder: Assets.albumPlaceholder.path,
fit: BoxFit.cover,
), ),
), ),
), const SizedBox(height: 60),
const SizedBox(height: 60), Container(
Container( padding: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 16), alignment: Alignment.centerLeft,
alignment: Alignment.centerLeft, child: Column(
child: Column( crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ AutoSizeText(
AutoSizeText( currentTrack?.name ?? context.l10n.not_playing,
currentTrack?.name ?? context.l10n.not_playing, style: const TextStyle(fontSize: 22),
style: const TextStyle(fontSize: 22), maxFontSize: 22,
maxFontSize: 22, maxLines: 1,
maxLines: 1, textAlign: TextAlign.start,
textAlign: TextAlign.start,
),
if (isLocalTrack)
Text(
currentTrack.artists?.asString() ?? "",
style: theme.typography.normal
.copyWith(fontWeight: FontWeight.bold),
)
else
ArtistLink(
artists: currentTrack?.artists ?? [],
textStyle: theme.typography.normal
.copyWith(fontWeight: FontWeight.bold),
onRouteChange: (route) {
panelController.close();
GoRouter.of(context).push(route);
},
onOverflowArtistClick: () => ServiceUtils.pushNamed(
context,
TrackPage.name,
pathParameters: {
"id": currentTrack!.id!,
},
),
), ),
], if (isLocalTrack)
), Text(
), currentTrack.artists?.asString() ?? "",
const SizedBox(height: 10), style: theme.typography.normal
const PlayerControls(), .copyWith(fontWeight: FontWeight.bold),
const SizedBox(height: 25), )
const PlayerActions( else
mainAxisAlignment: MainAxisAlignment.spaceEvenly, ArtistLink(
showQueue: false, artists: currentTrack?.artists ?? [],
), textStyle: theme.typography.normal
const SizedBox(height: 10), .copyWith(fontWeight: FontWeight.bold),
Row( onRouteChange: (route) {
mainAxisAlignment: MainAxisAlignment.spaceEvenly, panelController.close();
children: [ GoRouter.of(context).push(route);
const SizedBox(width: 10), },
Expanded( onOverflowArtistClick: () => ServiceUtils.pushNamed(
child: OutlineButton( context,
leading: const Icon(SpotubeIcons.queue), TrackPage.name,
child: Text(context.l10n.queue), pathParameters: {
onPressed: () { "id": currentTrack!.id!,
openDrawer(
context: context,
barrierDismissible: true,
draggable: true,
barrierColor: Colors.black.withAlpha(100),
borderRadius: BorderRadius.circular(10),
transformBackdrop: false,
position: OverlayPosition.bottom,
surfaceBlur: context.theme.surfaceBlur,
surfaceOpacity: 0.7,
expands: true,
builder: (context) => Consumer(
builder: (context, ref, _) {
final playlist = ref.watch(
audioPlayerProvider,
);
final playlistNotifier =
ref.read(audioPlayerProvider.notifier);
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight:
MediaQuery.of(context).size.height *
0.8,
),
child: PlayerQueue.fromAudioPlayerNotifier(
floating: false,
playlist: playlist,
notifier: playlistNotifier,
),
);
}, },
), ),
); ),
}, ],
),
), ),
if (auth.asData?.value != null) const SizedBox(width: 10), ),
if (auth.asData?.value != null) const SizedBox(height: 10),
const PlayerControls(),
const SizedBox(height: 25),
const PlayerActions(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
showQueue: false,
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
const SizedBox(width: 10),
Expanded( Expanded(
child: OutlineButton( child: OutlineButton(
leading: const Icon(SpotubeIcons.music), leading: const Icon(SpotubeIcons.queue),
child: Text(context.l10n.lyrics), child: Text(context.l10n.queue),
onPressed: () { onPressed: () {
showModalBottomSheet( openDrawer(
context: context, context: context,
isDismissible: true, barrierDismissible: true,
enableDrag: true, draggable: true,
isScrollControlled: true,
backgroundColor: Colors.black.withAlpha(100),
barrierColor: Colors.black.withAlpha(100), barrierColor: Colors.black.withAlpha(100),
shape: const RoundedRectangleBorder( borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.only( transformBackdrop: false,
topLeft: Radius.circular(20), position: OverlayPosition.bottom,
topRight: Radius.circular(20), surfaceBlur: context.theme.surfaceBlur,
), surfaceOpacity: 0.7,
expands: true,
builder: (context) => Consumer(
builder: (context, ref, _) {
final playlist = ref.watch(
audioPlayerProvider,
);
final playlistNotifier =
ref.read(audioPlayerProvider.notifier);
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight:
MediaQuery.of(context).size.height *
0.8,
),
child: PlayerQueue.fromAudioPlayerNotifier(
floating: false,
playlist: playlist,
notifier: playlistNotifier,
),
);
},
), ),
builder: (context) =>
const LyricsPage(isModal: true),
); );
}, },
), ),
), ),
const SizedBox(width: 10), if (auth.asData?.value != null) const SizedBox(width: 10),
], if (auth.asData?.value != null)
), Expanded(
const SizedBox(height: 25), child: OutlineButton(
Padding( leading: const Icon(SpotubeIcons.music),
padding: const EdgeInsets.symmetric(horizontal: 16), child: Text(context.l10n.lyrics),
child: Consumer(builder: (context, ref, _) { onPressed: () {
final volume = ref.watch(volumeProvider); showModalBottomSheet(
return VolumeSlider( context: context,
fullWidth: true, isDismissible: true,
value: volume, enableDrag: true,
onChanged: (value) { isScrollControlled: true,
ref.read(volumeProvider.notifier).setVolume(value); backgroundColor: Colors.black.withAlpha(100),
}, barrierColor: Colors.black.withAlpha(100),
); shape: const RoundedRectangleBorder(
}), borderRadius: BorderRadius.only(
), topLeft: Radius.circular(20),
], topRight: Radius.circular(20),
),
),
builder: (context) =>
const LyricsPage(isModal: true),
);
},
),
),
const SizedBox(width: 10),
],
),
const SizedBox(height: 25),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Consumer(builder: (context, ref, _) {
final volume = ref.watch(volumeProvider);
return VolumeSlider(
fullWidth: true,
value: volume,
onChanged: (value) {
ref.read(volumeProvider.notifier).setVolume(value);
},
);
}),
),
],
),
), ),
), ),
), ),

View File

@ -5,6 +5,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/modules/player/player_queue.dart'; import 'package:spotube/modules/player/player_queue.dart';
import 'package:spotube/modules/player/sibling_tracks_sheet.dart'; import 'package:spotube/modules/player/sibling_tracks_sheet.dart';
import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart';
@ -118,23 +119,54 @@ class PlayerActions extends HookConsumerWidget {
tooltip: TooltipContainer( tooltip: TooltipContainer(
child: Text(context.l10n.alternative_track_sources)), child: Text(context.l10n.alternative_track_sources)),
child: IconButton.ghost( child: IconButton.ghost(
enabled: playlist.activeTrack != null,
icon: const Icon(SpotubeIcons.alternativeRoute), icon: const Icon(SpotubeIcons.alternativeRoute),
onPressed: playlist.activeTrack != null onPressed: () {
? () { final screenSize = MediaQuery.sizeOf(context);
openDrawer( if (screenSize.mdAndUp) {
context: context, showPopover(
position: OverlayPosition.bottom, alignment: Alignment.bottomCenter,
barrierDismissible: true, context: context,
draggable: true, builder: (context) {
barrierColor: Colors.black.withValues(alpha: .2), return SurfaceCard(
borderRadius: BorderRadius.circular(10), padding: EdgeInsets.zero,
transformBackdrop: false, child: ConstrainedBox(
builder: (context) { constraints: const BoxConstraints(
return SiblingTracksSheet(floating: floatingQueue); maxHeight: 600,
}, maxWidth: 500,
),
child: SiblingTracksSheet(floating: floatingQueue),
),
); );
} },
: null, );
} else {
openDrawer(
context: context,
position: OverlayPosition.bottom,
barrierDismissible: true,
draggable: true,
barrierColor: Colors.black.withValues(alpha: .2),
borderRadius: BorderRadius.circular(10),
transformBackdrop: false,
surfaceBlur: context.theme.surfaceBlur,
surfaceOpacity: context.theme.surfaceOpacity,
builder: (context) {
return Card(
borderWidth: 0,
borderColor: Colors.transparent,
padding: EdgeInsets.zero,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: screenSize.height * .8,
),
child: SiblingTracksSheet(floating: floatingQueue),
),
);
},
);
}
},
), ),
), ),
if (!kIsWeb && !isLocalTrack) if (!kIsWeb && !isLocalTrack)

View File

@ -1,16 +1,15 @@
import 'dart:ui';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart' show ListTile, Material, MaterialType;
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/duration.dart';
import 'package:spotube/hooks/utils/use_debounce.dart'; import 'package:spotube/hooks/utils/use_debounce.dart';
@ -152,13 +151,6 @@ class SiblingTracksSheet extends HookConsumerWidget {
[activeTrack, isFetchingActiveTrack], [activeTrack, isFetchingActiveTrack],
); );
final borderRadius = floating
? BorderRadius.circular(10)
: const BorderRadius.only(
topLeft: Radius.circular(10),
topRight: Radius.circular(10),
);
useEffect(() { useEffect(() {
if (activeTrack is SourcedTrack && activeTrack.siblings.isEmpty) { if (activeTrack is SourcedTrack && activeTrack.siblings.isEmpty) {
activeTrackNotifier.populateSibling(); activeTrackNotifier.populateSibling();
@ -170,9 +162,17 @@ class SiblingTracksSheet extends HookConsumerWidget {
(SourceInfo sourceInfo) { (SourceInfo sourceInfo) {
final icon = sourceInfoToIconMap[sourceInfo.runtimeType]; final icon = sourceInfoToIconMap[sourceInfo.runtimeType];
return ListTile( return ListTile(
hoverColor: theme.colorScheme.primary.withOpacity(.1),
dense: true,
subtitleTextStyle: theme.typography.small.copyWith(
color: theme.colorScheme.mutedForeground,
),
titleTextStyle: theme.typography.normal,
leadingAndTrailingTextStyle: theme.typography.normal,
title: Text(sourceInfo.title), title: Text(sourceInfo.title),
horizontalTitleGap: 0,
leading: Padding( leading: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.only(top: 8.0, right: 8.0),
child: UniversalImage( child: UniversalImage(
path: sourceInfo.thumbnail, path: sourceInfo.thumbnail,
height: 60, height: 60,
@ -192,12 +192,13 @@ class SiblingTracksSheet extends HookConsumerWidget {
enabled: !isFetchingActiveTrack, enabled: !isFetchingActiveTrack,
selected: !isFetchingActiveTrack && selected: !isFetchingActiveTrack &&
sourceInfo.id == (activeTrack as SourcedTrack).sourceInfo.id, sourceInfo.id == (activeTrack as SourcedTrack).sourceInfo.id,
selectedTileColor: theme.popupMenuTheme.color, selectedTileColor: theme.colorScheme.primary.withOpacity(.1),
selectedColor: theme.colorScheme.primary,
onTap: () { onTap: () {
if (!isFetchingActiveTrack && if (!isFetchingActiveTrack &&
sourceInfo.id != (activeTrack as SourcedTrack).sourceInfo.id) { sourceInfo.id != (activeTrack as SourcedTrack).sourceInfo.id) {
activeTrackNotifier.swapSibling(sourceInfo); activeTrackNotifier.swapSibling(sourceInfo);
Navigator.of(context).pop(); closeDrawer(context);
} }
}, },
); );
@ -205,131 +206,127 @@ class SiblingTracksSheet extends HookConsumerWidget {
[activeTrack, siblings], [activeTrack, siblings],
); );
final mediaQuery = MediaQuery.of(context); final scale = context.theme.scaling;
return SafeArea(
child: ClipRRect(
borderRadius: borderRadius,
clipBehavior: Clip.hardEdge,
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 12.0,
sigmaY: 12.0,
),
child: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: Container(
height: isSearching.value && mediaQuery.smAndDown
? mediaQuery.size.height - 50
: mediaQuery.size.height * .6,
decoration: BoxDecoration(
borderRadius: borderRadius,
color:
theme.colorScheme.surfaceContainerHighest.withOpacity(.5),
),
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
centerTitle: true,
title: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: !isSearching.value
? Text(
context.l10n.alternative_track_sources,
style: theme.textTheme.headlineSmall,
)
: TextField(
autofocus: true,
controller: searchController,
decoration: InputDecoration(
hintText: context.l10n.search,
hintStyle: theme.textTheme.headlineSmall,
border: InputBorder.none,
),
style: theme.textTheme.headlineSmall,
),
),
automaticallyImplyLeading: false,
backgroundColor: Colors.transparent,
actions: [
if (!isSearching.value)
IconButton(
icon: const Icon(SpotubeIcons.search, size: 18),
onPressed: () {
isSearching.value = true;
},
)
else ...[
if (preferences.audioSource == AudioSource.piped)
PopupMenuButton(
icon: const Icon(SpotubeIcons.filter, size: 18),
onSelected: (SearchMode mode) {
searchMode.value = mode;
},
initialValue: searchMode.value,
itemBuilder: (context) => SearchMode.values
.map(
(e) => PopupMenuItem(
value: e,
child: Text(e.label),
),
)
.toList(),
),
IconButton(
icon: const Icon(SpotubeIcons.close, size: 18),
onPressed: () {
isSearching.value = false;
},
),
]
],
),
body: Padding(
padding: const EdgeInsets.all(8.0),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) =>
FadeTransition(opacity: animation, child: child),
child: InterScrollbar(
controller: controller,
child: switch (isSearching.value) {
false => ListView.builder(
controller: controller,
itemCount: siblings.length,
itemBuilder: (context, index) =>
itemBuilder(siblings[index]),
),
true => FutureBuilder(
future: searchRequest,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text(snapshot.error.toString()),
);
} else if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator());
}
return InterScrollbar( return SafeArea(
controller: controller, child: Column(
child: ListView.builder( mainAxisSize: MainAxisSize.min,
controller: controller, children: [
itemCount: snapshot.data!.length, Padding(
itemBuilder: (context, index) => padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16),
itemBuilder(snapshot.data![index]), child: Row(
), spacing: 5,
); children: [
}, AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: !isSearching.value
? Text(
context.l10n.alternative_track_sources,
style: theme.typography.bold,
)
: Flexible(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 320 * scale,
maxHeight: 38 * scale,
),
child: TextField(
autofocus: true,
controller: searchController,
placeholder: Text(context.l10n.search),
style: theme.typography.bold,
),
), ),
),
),
const Spacer(),
if (!isSearching.value)
IconButton.outline(
icon: const Icon(SpotubeIcons.search, size: 18),
onPressed: () {
isSearching.value = true;
},
)
else ...[
if (preferences.audioSource == AudioSource.piped)
IconButton.outline(
icon: const Icon(SpotubeIcons.filter, size: 18),
onPressed: () {
showPopover(
context: context,
alignment: Alignment.bottomRight,
builder: (context) {
return DropdownMenu(
children: SearchMode.values
.map(
(e) => MenuButton(
onPressed: (context) {
searchMode.value = e;
},
enabled: searchMode.value != e,
child: Text(e.label),
),
)
.toList(),
);
},
);
}, },
), ),
IconButton.outline(
icon: const Icon(SpotubeIcons.close, size: 18),
onPressed: () {
isSearching.value = false;
},
), ),
]
],
),
),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) =>
FadeTransition(opacity: animation, child: child),
child: InterScrollbar(
controller: controller,
child: Material(
type: MaterialType.transparency,
child: switch (isSearching.value) {
false => ListView.builder(
padding: const EdgeInsets.all(8.0),
controller: controller,
itemCount: siblings.length,
itemBuilder: (context, index) =>
itemBuilder(siblings[index]),
),
true => FutureBuilder(
future: searchRequest,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text(snapshot.error.toString()),
);
} else if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator());
}
return ListView.builder(
padding: const EdgeInsets.all(8.0),
controller: controller,
itemCount: snapshot.data!.length,
itemBuilder: (context, index) =>
itemBuilder(snapshot.data![index]),
);
},
),
},
), ),
), ),
), ),
), ),
), ],
), ),
); );
} }