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 {
await panelController.close();
},
child: Scaffold(
headers: [
SafeArea(
child: TitleBar(
surfaceOpacity: 0,
surfaceBlur: 0,
leading: [
IconButton.ghost(
icon: const Icon(SpotubeIcons.angleDown, size: 18),
onPressed: panelController.close,
)
],
trailing: [
if (currentTrack is YoutubeSourcedTrack)
TextButton(
leading: Assets.logos.songlinkTransparent.image(
width: 20,
height: 20,
color: theme.colorScheme.foreground,
),
onPressed: () {
final url = "https://song.link/s/${currentTrack.id}";
child: SurfaceCard(
borderWidth: 0,
surfaceOpacity: 0.9,
padding: EdgeInsets.zero,
child: Scaffold(
backgroundColor: Colors.transparent,
headers: [
SafeArea(
child: TitleBar(
surfaceOpacity: 0,
surfaceBlur: 0,
leading: [
IconButton.ghost(
icon: const Icon(SpotubeIcons.angleDown, size: 18),
onPressed: panelController.close,
)
],
trailing: [
if (currentTrack is YoutubeSourcedTrack)
TextButton(
leading: Assets.logos.songlinkTransparent.image(
width: 20,
height: 20,
color: theme.colorScheme.foreground,
),
onPressed: () {
final url = "https://song.link/s/${currentTrack.id}";
launchUrlString(url);
},
child: Text(context.l10n.song_link),
),
Tooltip(
tooltip: TooltipContainer(
child: Text(context.l10n.details),
),
child: IconButton.ghost(
icon: const Icon(SpotubeIcons.info, size: 18),
onPressed: currentTrack == null
? null
: () {
showDialog(
context: context,
builder: (context) {
return TrackDetailsDialog(
track: currentTrack,
);
});
},
),
)
],
launchUrlString(url);
},
child: Text(context.l10n.song_link),
),
Tooltip(
tooltip: TooltipContainer(
child: Text(context.l10n.details),
),
child: IconButton.ghost(
icon: const Icon(SpotubeIcons.info, size: 18),
onPressed: currentTrack == null
? null
: () {
showDialog(
context: context,
builder: (context) {
return TrackDetailsDialog(
track: currentTrack,
);
});
},
),
)
],
),
),
),
],
child: SingleChildScrollView(
controller: scrollController,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
Container(
margin: const EdgeInsets.all(8),
constraints:
const BoxConstraints(maxHeight: 300, maxWidth: 300),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(100),
spreadRadius: 2,
blurRadius: 10,
offset: Offset.zero,
],
child: SingleChildScrollView(
controller: scrollController,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
Container(
margin: const EdgeInsets.all(8),
constraints:
const BoxConstraints(maxHeight: 300, maxWidth: 300),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(100),
spreadRadius: 2,
blurRadius: 10,
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),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.centerLeft,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AutoSizeText(
currentTrack?.name ?? context.l10n.not_playing,
style: const TextStyle(fontSize: 22),
maxFontSize: 22,
maxLines: 1,
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!,
},
),
const SizedBox(height: 60),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.centerLeft,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AutoSizeText(
currentTrack?.name ?? context.l10n.not_playing,
style: const TextStyle(fontSize: 22),
maxFontSize: 22,
maxLines: 1,
textAlign: TextAlign.start,
),
],
),
),
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(
child: OutlineButton(
leading: const Icon(SpotubeIcons.queue),
child: Text(context.l10n.queue),
onPressed: () {
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 (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 (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(
child: OutlineButton(
leading: const Icon(SpotubeIcons.music),
child: Text(context.l10n.lyrics),
leading: const Icon(SpotubeIcons.queue),
child: Text(context.l10n.queue),
onPressed: () {
showModalBottomSheet(
openDrawer(
context: context,
isDismissible: true,
enableDrag: true,
isScrollControlled: true,
backgroundColor: Colors.black.withAlpha(100),
barrierDismissible: true,
draggable: true,
barrierColor: Colors.black.withAlpha(100),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
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,
),
);
},
),
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);
},
);
}),
),
],
if (auth.asData?.value != null) const SizedBox(width: 10),
if (auth.asData?.value != null)
Expanded(
child: OutlineButton(
leading: const Icon(SpotubeIcons.music),
child: Text(context.l10n.lyrics),
onPressed: () {
showModalBottomSheet(
context: context,
isDismissible: true,
enableDrag: true,
isScrollControlled: true,
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:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/modules/player/player_queue.dart';
import 'package:spotube/modules/player/sibling_tracks_sheet.dart';
import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart';
@ -118,23 +119,54 @@ class PlayerActions extends HookConsumerWidget {
tooltip: TooltipContainer(
child: Text(context.l10n.alternative_track_sources)),
child: IconButton.ghost(
enabled: playlist.activeTrack != null,
icon: const Icon(SpotubeIcons.alternativeRoute),
onPressed: playlist.activeTrack != null
? () {
openDrawer(
context: context,
position: OverlayPosition.bottom,
barrierDismissible: true,
draggable: true,
barrierColor: Colors.black.withValues(alpha: .2),
borderRadius: BorderRadius.circular(10),
transformBackdrop: false,
builder: (context) {
return SiblingTracksSheet(floating: floatingQueue);
},
onPressed: () {
final screenSize = MediaQuery.sizeOf(context);
if (screenSize.mdAndUp) {
showPopover(
alignment: Alignment.bottomCenter,
context: context,
builder: (context) {
return SurfaceCard(
padding: EdgeInsets.zero,
child: ConstrainedBox(
constraints: const BoxConstraints(
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)

View File

@ -1,16 +1,15 @@
import 'dart:ui';
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: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/spotube_icons.dart';
import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/duration.dart';
import 'package:spotube/hooks/utils/use_debounce.dart';
@ -152,13 +151,6 @@ class SiblingTracksSheet extends HookConsumerWidget {
[activeTrack, isFetchingActiveTrack],
);
final borderRadius = floating
? BorderRadius.circular(10)
: const BorderRadius.only(
topLeft: Radius.circular(10),
topRight: Radius.circular(10),
);
useEffect(() {
if (activeTrack is SourcedTrack && activeTrack.siblings.isEmpty) {
activeTrackNotifier.populateSibling();
@ -170,9 +162,17 @@ class SiblingTracksSheet extends HookConsumerWidget {
(SourceInfo sourceInfo) {
final icon = sourceInfoToIconMap[sourceInfo.runtimeType];
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),
horizontalTitleGap: 0,
leading: Padding(
padding: const EdgeInsets.all(8.0),
padding: const EdgeInsets.only(top: 8.0, right: 8.0),
child: UniversalImage(
path: sourceInfo.thumbnail,
height: 60,
@ -192,12 +192,13 @@ class SiblingTracksSheet extends HookConsumerWidget {
enabled: !isFetchingActiveTrack,
selected: !isFetchingActiveTrack &&
sourceInfo.id == (activeTrack as SourcedTrack).sourceInfo.id,
selectedTileColor: theme.popupMenuTheme.color,
selectedTileColor: theme.colorScheme.primary.withOpacity(.1),
selectedColor: theme.colorScheme.primary,
onTap: () {
if (!isFetchingActiveTrack &&
sourceInfo.id != (activeTrack as SourcedTrack).sourceInfo.id) {
activeTrackNotifier.swapSibling(sourceInfo);
Navigator.of(context).pop();
closeDrawer(context);
}
},
);
@ -205,131 +206,127 @@ class SiblingTracksSheet extends HookConsumerWidget {
[activeTrack, siblings],
);
final mediaQuery = MediaQuery.of(context);
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());
}
final scale = context.theme.scaling;
return InterScrollbar(
controller: controller,
child: ListView.builder(
controller: controller,
itemCount: snapshot.data!.length,
itemBuilder: (context, index) =>
itemBuilder(snapshot.data![index]),
),
);
},
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 16),
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]),
);
},
),
},
),
),
),
),
),
],
),
);
}