Play playlist from PlaylistCard support

Login page tuned
Replace loading texts with Circular progress bar
Sidebar icons are rounded now
New Icon & banner
This commit is contained in:
Kingkor Roy Tirtho 2022-01-04 00:00:12 +06:00
parent 0ef44709fa
commit db42e81501
10 changed files with 348 additions and 107 deletions

68
assets/spotube-logo.svg Normal file
View File

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="0 0 500 500" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:bx="https://boxy-svg.com">
<defs>
<filter id="inner-shadow-filter-1" x="-500%" y="-500%" width="1000%" height="1000%" bx:preset="inner-shadow 1 0 0 9 0.5 rgba(0,0,0,0.7)">
<feOffset dx="0" dy="0"/>
<feGaussianBlur stdDeviation="9"/>
<feComposite operator="out" in="SourceGraphic"/>
<feComponentTransfer result="choke">
<feFuncA type="linear" slope="1"/>
</feComponentTransfer>
<feFlood flood-color="rgba(0,0,0,0.7)" result="color"/>
<feComposite operator="in" in="color" in2="choke" result="shadow"/>
<feComposite operator="over" in="shadow" in2="SourceGraphic"/>
</filter>
<linearGradient id="gradient-0-0" gradientUnits="userSpaceOnUse" x1="47.146" y1="18.044" x2="47.146" y2="75.354" xlink:href="#gradient-0"/>
<linearGradient id="gradient-0">
<stop offset="0.031" style="stop-color: rgb(255, 115, 0);"/>
<stop offset="1" style="stop-color: rgb(1, 107, 255);"/>
</linearGradient>
<filter id="inner-shadow-filter-0" x="-500%" y="-500%" width="1000%" height="1000%" bx:preset="inner-shadow 1 0 0 3 0.5 rgba(0,0,0,0.7)">
<feOffset dx="0" dy="0"/>
<feGaussianBlur stdDeviation="3"/>
<feComposite operator="out" in="SourceGraphic"/>
<feComponentTransfer result="choke">
<feFuncA type="linear" slope="1"/>
</feComponentTransfer>
<feFlood flood-color="rgba(0,0,0,0.7)" result="color"/>
<feComposite operator="in" in="color" in2="choke" result="shadow"/>
<feComposite operator="over" in="shadow" in2="SourceGraphic"/>
</filter>
<linearGradient id="gradient-4-1" gradientUnits="userSpaceOnUse" x1="82.026" y1="144.832" x2="82.026" y2="264.462" xlink:href="#gradient-4"/>
<linearGradient id="gradient-4">
<stop offset="0.04" style="stop-color: rgb(255, 107, 1);"/>
<stop offset="0.598" style="stop-color: rgb(0, 234, 255);"/>
<stop offset="0.909" style="stop-color: rgb(8, 85, 140);"/>
</linearGradient>
<linearGradient id="gradient-4-2" gradientUnits="userSpaceOnUse" x1="143.693" y1="22.804" x2="143.693" y2="264.582" xlink:href="#gradient-4"/>
<linearGradient id="gradient-4-0" gradientUnits="userSpaceOnUse" x1="205.862" y1="146.28" x2="205.862" y2="265.91" xlink:href="#gradient-4"/>
</defs>
<ellipse style="paint-order: fill; fill: rgb(255, 255, 255); filter: url(#inner-shadow-filter-1);" cx="249.704" cy="250.295" rx="241.45" ry="241.45"/>
<g transform="matrix(0.372585, 0, 0, 0.376313, 245.872849, 308.773438)" style="">
<g style="stroke: none; stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: none; fill-rule: nonzero; opacity: 1;" transform="translate(-175.05 -175.05000000000004) scale(3.89 3.89)">
<path d="M 91.835 18.32 C 91.637 18.132 91.374 18.036 91.096 18.046 C 84.617 18.367 77.578 19.444 68.948 21.435 C 68.677 21.498 68.444 21.67 68.305 21.911 C 68.166 22.152 68.135 22.44 68.217 22.705 L 69.055 25.409 C 62.692 22.996 53.742 21.607 45.995 21.912 C 43.155 21.912 39.913 23.321 36.95 25.412 L 25.235 25.412 L 26.074 22.704 C 26.157 22.438 26.124 22.151 25.986 21.91 C 25.848 21.669 25.615 21.496 25.344 21.434 C 16.714 19.443 9.676 18.366 3.196 18.045 C 2.927 18.033 2.656 18.13 2.457 18.319 C 2.258 18.509 2.146 18.771 2.146 19.045 L 2.146 53.387 C 2.146 53.94 2.594 54.387 3.146 54.387 L 15.524 54.387 C 15.962 54.387 16.35 54.102 16.479 53.683 L 16.951 52.16 C 27.138 64.032 37.497 74.935 45.585 74.935 C 47.142 74.935 48.614 74.524 49.986 73.645 L 50.197 73.859 C 51.141 74.815 52.406 75.346 53.758 75.354 C 53.769 75.354 53.779 75.354 53.79 75.354 C 55.129 75.354 56.387 74.839 57.336 73.903 C 58.17 73.078 58.635 72.03 58.772 70.947 C 59.702 71.718 60.833 72.127 61.978 72.127 C 63.259 72.126 64.542 71.643 65.525 70.673 C 66.634 69.577 67.105 68.092 66.981 66.648 C 67.427 66.758 67.878 66.833 68.331 66.833 C 69.614 66.833 70.869 66.39 71.779 65.491 C 72.735 64.547 73.266 63.282 73.274 61.93 C 73.282 60.578 72.767 59.308 71.887 58.423 C 71.519 57.97 71.139 57.535 70.766 57.089 L 77.582 52.94 L 77.812 53.682 C 77.942 54.101 78.329 54.386 78.767 54.386 L 91.146 54.386 C 91.699 54.386 92.146 53.939 92.146 53.386 L 92.146 19.045 C 92.146 18.771 92.034 18.509 91.835 18.32 Z M 14.787 52.387 L 4.146 52.387 L 4.146 20.102 C 9.952 20.461 16.268 21.437 23.845 23.145 L 14.787 52.387 Z M 70.373 64.067 C 69.234 65.193 67.063 65.072 65.817 63.809 C 65.8 63.792 65.778 63.786 65.76 63.771 C 65.693 63.694 65.642 63.608 65.569 63.534 L 54.619 52.448 C 54.229 52.056 53.598 52.052 53.204 52.439 C 52.811 52.828 52.808 53.46 53.195 53.854 L 64.145 64.94 C 64.714 65.515 65.025 66.283 65.02 67.1 C 65.015 67.916 64.695 68.68 64.119 69.248 C 62.924 70.431 60.991 70.416 59.809 69.222 L 57.384 66.767 C 57.382 66.765 57.381 66.763 57.38 66.762 L 46.43 55.677 C 46.041 55.286 45.408 55.281 45.016 55.668 C 44.623 56.057 44.619 56.689 45.007 57.083 L 55.956 68.169 C 57.138 69.364 57.126 71.298 55.93 72.479 C 54.734 73.661 52.8 73.647 51.62 72.453 L 38.24 58.908 C 37.851 58.516 37.218 58.51 36.826 58.9 C 36.433 59.288 36.429 59.921 36.817 60.314 L 48.528 72.169 C 41.093 76.143 28.778 62.93 17.651 49.901 L 24.616 27.414 L 34.431 27.414 C 31.69 29.846 29.43 32.75 28.339 35.397 C 26.943 38.783 27.852 40.687 28.86 41.688 C 28.886 41.714 28.914 41.739 28.943 41.762 C 32.786 44.809 36.571 45.577 42.466 39.479 C 44.467 39.601 46.171 39.254 47.65 38.415 C 55.956 44.222 63.587 51.376 70.399 59.758 C 71.581 60.953 71.569 62.887 70.373 64.067 Z M 69.464 55.541 C 63.058 48.131 55.937 41.698 48.248 36.395 C 47.907 36.159 47.455 36.159 47.114 36.394 C 45.792 37.301 44.175 37.645 42.17 37.449 C 41.859 37.415 41.556 37.533 41.343 37.759 C 35.758 43.702 32.999 42.415 30.234 40.232 C 29.238 39.193 29.657 37.448 30.188 36.159 C 32.412 30.761 40.301 23.913 46.034 23.912 C 54.206 23.599 63.683 25.188 69.82 27.879 L 76.972 50.97 L 69.464 55.541 Z M 90.146 52.387 L 79.504 52.387 L 70.446 23.145 C 78.023 21.437 84.34 20.461 90.145 20.102 L 90.145 52.387 Z" style="stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; stroke: url(#gradient-0-0); fill: rgb(28, 28, 29); stroke-width: 3.33887px; paint-order: stroke;" stroke-linecap="round"/>
</g>
</g>
<g transform="matrix(1.289989, 0, 0, 1.28817, 62.9105, 31.643152)" style="filter: url(#inner-shadow-filter-0);">
<g>
<path d="M 71.421 155.437 L 71.421 253.857 C 71.421 259.724 76.162 264.462 82.026 264.462 C 87.88 264.462 92.631 259.724 92.631 253.857 L 92.631 155.437 C 92.631 149.581 87.88 144.832 82.026 144.832 C 76.162 144.832 71.421 149.576 71.421 155.437 Z" style="stroke-width: 9.80924px; stroke: url(#gradient-4-1); fill: rgb(29, 29, 29); stroke-linecap: round; stroke-linejoin: round;"/>
<path d="M29.456,264.582h23.351v-116.85c0.064-0.56,0.166-1.119,0.166-1.693c0-50.412,40.69-91.42,90.698-91.42 c50.002,0,90.692,41.008,90.692,91.42c0,0.771,0.113,1.518,0.228,2.263v116.28h23.354c16.254,0,29.442-13.64,29.442-30.469 v-60.936c0-13.878-8.989-25.57-21.261-29.249c-1.129-66.971-55.608-121.124-122.45-121.124 c-66.86,0-121.347,54.158-122.465,121.15C8.956,147.638,0,159.32,0,173.187v60.926C0,250.932,13.187,264.582,29.456,264.582z" style="stroke-width: 11.3184px; stroke: url(#gradient-4-2); fill: rgb(29, 29, 29); stroke-linecap: round; stroke-linejoin: round;"/>
<path d="M 195.258 156.885 L 195.258 255.305 C 195.258 261.172 200.006 265.91 205.862 265.91 C 211.718 265.91 216.466 261.172 216.466 255.305 L 216.466 156.885 C 216.466 151.029 211.718 146.28 205.862 146.28 C 199.995 146.28 195.258 151.024 195.258 156.885 Z" style="stroke-width: 9.80924px; stroke: url(#gradient-4-0); fill: rgb(29, 29, 29); stroke-linecap: round; stroke-linejoin: round;"/>
</g>
</g>
<g transform="matrix(0.972684, 0, 0, 0.972684, 62.9105, 10.735223)" style=""/>
<g transform="matrix(0.972684, 0, 0, 0.972684, 62.9105, 10.735223)" style=""/>
<g transform="matrix(0.972684, 0, 0, 0.972684, 62.9105, 10.735223)" style=""/>
<g transform="matrix(0.972684, 0, 0, 0.972684, 62.9105, 10.735223)" style=""/>
<g transform="matrix(0.972684, 0, 0, 0.972684, 62.9105, 10.735223)" style=""/>
<g transform="matrix(0.972684, 0, 0, 0.972684, 62.9105, 10.735223)" style=""/>
<g transform="matrix(0.972684, 0, 0, 0.972684, 62.9105, 10.735223)" style=""/>
<g transform="matrix(0.972684, 0, 0, 0.972684, 62.9105, 10.735223)" style=""/>
<g transform="matrix(0.972684, 0, 0, 0.972684, 62.9105, 10.735223)" style=""/>
<g transform="matrix(0.972684, 0, 0, 0.972684, 62.9105, 10.735223)" style=""/>
<g transform="matrix(0.972684, 0, 0, 0.972684, 62.9105, 10.735223)" style=""/>
<g transform="matrix(0.972684, 0, 0, 0.972684, 62.9105, 10.735223)" style=""/>
<g transform="matrix(0.972684, 0, 0, 0.972684, 62.9105, 10.735223)" style=""/>
<g transform="matrix(0.972684, 0, 0, 0.972684, 62.9105, 10.735223)" style=""/>
<g transform="matrix(0.972684, 0, 0, 0.972684, 62.9105, 10.735223)" style=""/>
</svg>

After

Width:  |  Height:  |  Size: 9.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 736 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@ -33,8 +33,13 @@ class _HomeState extends State<Home> {
if (clientId != null && clientSecret != null) {
SpotifyApi spotifyApi = SpotifyApi(
SpotifyApiCredentials(clientId, clientSecret,
scopes: ["user-library-read", "user-library-modify"]),
SpotifyApiCredentials(clientId, clientSecret, scopes: [
"user-library-read",
"user-library-modify",
"user-read-private",
"user-read-email",
"playlist-read-collaborative"
]),
);
SpotifyApiCredentials credentials = await spotifyApi.getCredentials();
if (credentials.accessToken?.isNotEmpty ?? false) {
@ -89,7 +94,7 @@ class _HomeState extends State<Home> {
child: Row(
children: [
Container(
color: Colors.grey.shade100,
color: Colors.blueGrey[50],
constraints: const BoxConstraints(maxWidth: 230),
child: Material(
type: MaterialType.transparency,
@ -121,39 +126,50 @@ class _HomeState extends State<Home> {
),
),
// user name & settings
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"User's name",
style: TextStyle(fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.settings_outlined),
onPressed: () {}),
],
),
)
Consumer<SpotifyDI>(builder: (context, data, widget) {
return FutureBuilder<User>(
future: data.spotifyApi.me.get(),
builder: (context, snapshot) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
snapshot.data?.displayName ??
"User's name",
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
IconButton(
icon:
const Icon(Icons.settings_outlined),
onPressed: () {}),
],
),
);
},
);
})
],
),
),
),
// contents of the spotify
Consumer<SpotifyDI>(builder: (_, data, __) {
return Expanded(
child: Scrollbar(
child: PagedListView(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<Category>(
itemBuilder: (context, item, index) {
return CategoryCard(item);
},
)),
Expanded(
child: Scrollbar(
child: PagedListView(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<Category>(
itemBuilder: (context, item, index) {
return CategoryCard(item);
},
),
),
);
}),
),
),
],
),
),

View File

@ -1,3 +1,4 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@ -51,43 +52,61 @@ class _LoginState extends State<Login> {
return Consumer<Auth>(
builder: (context, authState, child) {
return Scaffold(
body: Container(
padding: EdgeInsets.all(8.0),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text("Add your spotify credentials to get started",
style: Theme.of(context).textTheme.headline3),
Text(
style: Theme.of(context).textTheme.headline4),
const Text(
"Don't worry, any of your credentials won't be collected or shared with anyone"),
TextField(
decoration: InputDecoration(
hintText: "Spotify Client ID", labelText: "ClientId"),
onChanged: (value) {
setState(() {
client_id = value;
});
},
),
TextField(
decoration: InputDecoration(
hintText: "Spotify Client Secret",
labelText: "ClientSecret"),
onChanged: (value) {
setState(() {
client_secret = value;
});
},
),
SizedBox(
const SizedBox(
height: 10,
),
MaterialButton(
color: Theme.of(context).buttonColor,
onPressed: () {
handleLogin(authState);
},
child: Text("Submit"),
)
Container(
constraints: const BoxConstraints(
maxWidth: 400,
),
child: Column(
children: [
TextField(
decoration: const InputDecoration(
hintText: "Spotify Client ID",
label: Text("ClientID"),
),
onChanged: (value) {
setState(() {
client_id = value;
});
},
),
const SizedBox(
height: 10,
),
TextField(
decoration: const InputDecoration(
hintText: "Spotify Client Secret",
label: Text("Client Secret"),
),
onChanged: (value) {
setState(() {
client_secret = value;
});
},
),
const SizedBox(
height: 10,
),
ElevatedButton(
onPressed: () {
handleLogin(authState);
},
child: const Text("Submit"),
)
],
),
),
],
),
),

View File

@ -115,22 +115,27 @@ class _PlayerState extends State<Player> {
}
Future playPlaylist(CurrentPlaylist playlist) async {
if (player.isRunning() && playlist.id != _currentPlaylistId) {
var playlistPath = "/tmp/playlist-${playlist.id}.txt";
File file = File(playlistPath);
var newPlaylist = playlistToStr(playlist);
try {
if (player.isRunning() && playlist.id != _currentPlaylistId) {
var playlistPath = "/tmp/playlist-${playlist.id}.txt";
File file = File(playlistPath);
var newPlaylist = playlistToStr(playlist);
if (!await file.exists()) {
await file.create();
if (!await file.exists()) {
await file.create();
}
await file.writeAsString(newPlaylist);
await player.loadPlaylist(playlistPath);
setState(() {
_currentPlaylistId = playlist.id;
_shuffled = false;
});
}
await file.writeAsString(newPlaylist);
await player.loadPlaylist(playlistPath);
setState(() {
_currentPlaylistId = playlist.id;
_shuffled = false;
});
} catch (e, stackTrace) {
print("[Player]: $e");
print(stackTrace);
}
}

View File

@ -1,11 +1,14 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/PlaylistView.dart';
import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/SpotifyDI.dart';
class PlaylistCard extends StatefulWidget {
PlaylistSimple playlist;
PlaylistCard(this.playlist);
final PlaylistSimple playlist;
const PlaylistCard(this.playlist, {Key? key}) : super(key: key);
@override
_PlaylistCardState createState() => _PlaylistCardState();
}
@ -22,7 +25,7 @@ class _PlaylistCardState extends State<PlaylistCard> {
));
},
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 200),
constraints: const BoxConstraints(maxWidth: 200),
child: Ink(
decoration: BoxDecoration(
color: Colors.white,
@ -30,7 +33,7 @@ class _PlaylistCardState extends State<PlaylistCard> {
boxShadow: [
BoxShadow(
blurRadius: 10,
offset: Offset(0, 3),
offset: const Offset(0, 3),
spreadRadius: 5,
color: Colors.grey.shade300,
)
@ -40,21 +43,66 @@ class _PlaylistCardState extends State<PlaylistCard> {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// thumbnail of the playlist
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: widget.playlist.images![0].url!),
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: widget.playlist.images![0].url!),
),
Positioned.directional(
textDirection: TextDirection.ltr,
bottom: 10,
end: 5,
child: Builder(builder: (context) {
Playback playback = context.watch<Playback>();
SpotifyDI data = context.watch<SpotifyDI>();
bool isPlaylistPlaying = playback.currentPlaylist !=
null &&
playback.currentPlaylist!.id == widget.playlist.id;
return ElevatedButton(
onPressed: () async {
if (isPlaylistPlaying) return;
List<Track> tracks = (await data.spotifyApi.playlists
.getTracksByPlaylistId(widget.playlist.id!)
.all())
.toList();
playback.setCurrentPlaylist = CurrentPlaylist(
tracks: tracks,
id: widget.playlist.id!,
name: widget.playlist.name!,
thumbnail: widget.playlist.images!.first.url!,
);
},
child: Icon(
isPlaylistPlaying
? Icons.pause_rounded
: Icons.play_arrow_rounded,
),
style: ButtonStyle(
shape: MaterialStateProperty.all(
const CircleBorder(),
),
padding: MaterialStateProperty.all(
const EdgeInsets.all(16),
),
),
);
}),
)
],
),
SizedBox(height: 5),
const SizedBox(height: 5),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.playlist.name!,
style: TextStyle(fontWeight: FontWeight.bold),
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),

View File

@ -45,7 +45,7 @@ class _PlaylistGenreViewState extends State<PlaylistGenreView> {
return const Center(child: Text("Error occurred"));
}
if (!snapshot.hasData) {
return const Center(child: Text("Loading.."));
return const CircularProgressIndicator.adaptive();
}
return Wrap(
children: snapshot.data!

View File

@ -15,7 +15,6 @@ class PlaylistView extends StatefulWidget {
class _PlaylistViewState extends State<PlaylistView> {
@override
Widget build(BuildContext context) {
Playback playback = context.read<Playback>();
return Consumer<SpotifyDI>(builder: (_, data, __) {
return Scaffold(
body: FutureBuilder<Iterable<Track>>(
@ -70,7 +69,7 @@ class _PlaylistViewState extends State<PlaylistView> {
snapshot.hasError
? const Center(child: Text("Error occurred"))
: !snapshot.hasData
? const Center(child: Text("Loading.."))
? const CircularProgressIndicator.adaptive()
: Expanded(
child: Scrollbar(
isAlwaysShown: true,

View File

@ -27,13 +27,38 @@ class MyApp extends StatelessWidget {
debugShowCheckedModeBanner: false,
title: 'Flutter Demo',
theme: ThemeData(
primaryColor: Colors.greenAccent[400],
primarySwatch: Colors.green,
buttonTheme: const ButtonThemeData(
buttonColor: Colors.green,
),
),
home: Home(),
primaryColor: Colors.greenAccent[400],
primarySwatch: Colors.green,
buttonTheme: const ButtonThemeData(
buttonColor: Colors.green,
),
textTheme: TextTheme(
bodyText1: TextStyle(color: Colors.grey[850]),
headline1: TextStyle(color: Colors.grey[850]),
headline2: TextStyle(color: Colors.grey[850]),
headline3: TextStyle(color: Colors.grey[850]),
headline4: TextStyle(color: Colors.grey[850]),
headline5: TextStyle(color: Colors.grey[850]),
headline6: TextStyle(color: Colors.grey[850]),
),
listTileTheme: ListTileThemeData(
iconColor: Colors.grey[850],
horizontalTitleGap: 0,
),
inputDecorationTheme: InputDecorationTheme(
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.green[400]!,
width: 2.0,
),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Colors.grey[800]!,
),
),
)),
home: const Home(),
),
);
}

View File

@ -7,7 +7,7 @@ class SideBarTiles {
}
List<SideBarTiles> sidebarTileList = [
SideBarTiles(icon: Icons.home_filled, title: "Browse"),
SideBarTiles(icon: Icons.search, title: "Search"),
SideBarTiles(icon: Icons.home_rounded, title: "Browse"),
SideBarTiles(icon: Icons.search_rounded, title: "Search"),
SideBarTiles(icon: Icons.library_books_rounded, title: "Library"),
];