freezer/lib/ui/tiles.dart
Pato05 4b5d0bd09c
improve player screen with blurred album art
ui improvements in lyrics screen
animated bars when track is playing
fix back button when player screen is open
instantly pop when track is changed in queue list
2024-04-29 16:23:22 +02:00

1010 lines
31 KiB
Dart

import 'package:audio_service/audio_service.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:freezer/api/deezer.dart';
import 'package:freezer/api/download.dart';
import 'package:freezer/api/player/audio_handler.dart';
import 'package:freezer/api/player/player_helper.dart';
import 'package:freezer/icons.dart';
import 'package:freezer/main.dart';
import 'package:freezer/translations.i18n.dart';
import 'package:mini_music_visualizer/mini_music_visualizer.dart';
import '../api/definitions.dart';
import 'cached_image.dart';
import 'dart:async';
typedef SecondaryTapCallback = void Function(TapUpDetails?);
VoidCallback? normalizeSecondary(SecondaryTapCallback? callback) {
if (callback == null) return null;
return () => callback.call(null);
}
class TrackCardTile extends StatelessWidget {
static const _kDefaultWidth = 424.0;
final VoidCallback onTap;
/// Hold or Right Click
final SecondaryTapCallback? onSecondary;
final Widget? trailing;
final String trackId;
final String title;
final String artist;
final String artUri;
final bool explicit;
final double? width;
const TrackCardTile({
required this.trackId,
required this.title,
required this.artist,
required this.artUri,
required this.explicit,
required this.onTap,
this.onSecondary,
this.trailing,
this.width,
super.key,
});
factory TrackCardTile.fromTrack(
Track track, {
required VoidCallback onTap,
SecondaryTapCallback? onSecondary,
Widget? trailing,
double? width,
}) =>
TrackCardTile(
trackId: track.id,
title: track.title!,
artist: track.artistString,
artUri: track.albumArt!.thumb,
explicit: track.explicit ?? false,
onSecondary: onSecondary,
onTap: onTap,
width: width,
trailing: trailing,
);
@override
Widget build(BuildContext context) {
return SizedBox(
width: width ?? _kDefaultWidth,
height: 64.0,
child: Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
onLongPress: normalizeSecondary(onSecondary),
onSecondaryTapUp: onSecondary,
child: Row(mainAxisSize: MainAxisSize.min, children: [
CachedImage(url: artUri, rounded: false),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, overflow: TextOverflow.ellipsis, maxLines: 1),
Text(
artist,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Theme.of(context).disabledColor),
)
]),
),
),
]),
),
),
);
}
}
class TrackTile extends StatelessWidget {
final VoidCallback? onTap;
/// Hold or Right Click
final SecondaryTapCallback? onSecondary;
final Widget? trailing;
final String trackId;
final String title;
final String artist;
final String artUri;
final bool explicit;
final String durationString;
/// Disable if not needed, makes app lag, and uses lots of resources
final bool checkTrackOffline;
const TrackTile({
required this.trackId,
required this.title,
required this.artist,
required this.artUri,
required this.explicit,
required this.durationString,
this.onTap,
this.onSecondary,
this.trailing,
this.checkTrackOffline = true,
super.key,
});
factory TrackTile.fromTrack(Track track,
{VoidCallback? onTap,
SecondaryTapCallback? onSecondary,
Widget? trailing,
bool checkTrackOffline = true}) =>
TrackTile(
trackId: track.id,
title: track.title!,
artist: track.artistString,
artUri: track.albumArt!.thumb,
explicit: track.explicit ?? false,
durationString: track.durationString,
onSecondary: onSecondary,
onTap: onTap,
trailing: trailing,
checkTrackOffline: checkTrackOffline,
);
factory TrackTile.fromMediaItem(MediaItem mediaItem,
{VoidCallback? onTap,
SecondaryTapCallback? onSecondary,
Widget? trailing,
bool checkTrackOffline = true}) =>
TrackTile(
trackId: mediaItem.id,
title: mediaItem.title,
artist: mediaItem.artist ?? '',
artUri: mediaItem.extras!['thumb'],
explicit: false,
durationString: Track.durationAsString(mediaItem.duration!),
onSecondary: onSecondary,
onTap: onTap,
trailing: trailing,
checkTrackOffline: checkTrackOffline,
);
@override
Widget build(BuildContext context) {
return GestureDetector(
onSecondaryTapUp: onSecondary,
child: ListTile(
title: StreamBuilder<MediaItem?>(
stream: audioHandler.mediaItem,
builder: (context, snapshot) {
final mediaItem = snapshot.data;
final bool isHighlighted = mediaItem?.id == trackId;
return Text(
title,
maxLines: 1,
overflow: TextOverflow.clip,
style: TextStyle(
color: isHighlighted
? Theme.of(context).colorScheme.primary
: null),
);
}),
subtitle: Text(
artist,
maxLines: 1,
),
leading: StreamBuilder<MediaItem?>(
initialData: audioHandler.mediaItem.value,
stream: audioHandler.mediaItem,
builder: (context, snapshot) {
final child = CachedImage(
url: artUri,
width: 48.0,
height: 48.0,
);
if (snapshot.data?.id == trackId) {
return Stack(children: [
child,
Positioned.fill(
child: DecoratedBox(
decoration: const BoxDecoration(color: Colors.black26),
child: Center(
child: SizedBox(
width: 18.0,
height: 16.0,
child: StreamBuilder<bool>(
stream: playerHelper.playing,
builder: (context, snapshot) {
return MiniMusicVisualizer(
color: Colors.white70,
animate: snapshot.data ?? false);
})),
)),
),
]);
}
return child;
}),
onTap: onTap,
onLongPress: normalizeSecondary(onSecondary),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (checkTrackOffline)
FutureBuilder<bool>(
future:
downloadManager.checkOffline(track: Track(id: trackId)),
builder: (context, snapshot) {
if (snapshot.data == true) {
return const Padding(
padding: EdgeInsets.symmetric(horizontal: 2.0),
child: Icon(
FreezerIcons.primitive_dot,
color: Colors.green,
size: 12.0,
),
);
}
return const SizedBox.shrink();
}),
if (explicit)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 2.0),
child: Text(
'E',
style: TextStyle(color: Colors.red),
),
),
SizedBox(
width: 42.0,
child: Text(
durationString,
textAlign: TextAlign.center,
),
),
if (trailing != null) trailing!
],
),
),
);
}
}
class AlbumTile extends StatelessWidget {
final Album? album;
final void Function()? onTap;
/// Hold or Right click
final SecondaryTapCallback? onSecondary;
final Widget? trailing;
const AlbumTile(this.album,
{super.key, this.onTap, this.onSecondary, this.trailing});
@override
Widget build(BuildContext context) {
return GestureDetector(
onSecondaryTapUp: onSecondary,
child: ListTile(
title: Text(
album!.title!,
maxLines: 1,
),
subtitle: Text(
album!.artistString,
maxLines: 1,
),
leading: CachedImage(
url: album!.art!.thumb,
width: 48,
),
onTap: onTap,
onLongPress: normalizeSecondary(onSecondary),
trailing: trailing,
),
);
}
}
class ArtistTile extends StatelessWidget {
final Artist? artist;
final void Function()? onTap;
/// Hold or Right click
final SecondaryTapCallback? onSecondary;
const ArtistTile(this.artist, {super.key, this.onTap, this.onSecondary});
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
onTap: onTap,
onLongPress: normalizeSecondary(onSecondary),
onSecondaryTapUp: onSecondary,
child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
const SizedBox(height: 4.0),
CachedImage(
url: artist!.picture!.thumb,
circular: true,
width: 100,
),
const SizedBox(height: 8.0),
Text(
artist!.name!,
maxLines: 1,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14.0),
),
const SizedBox(height: 4),
]));
}
}
class PlaylistTile extends StatelessWidget {
final Playlist? playlist;
final void Function()? onTap;
final SecondaryTapCallback? onSecondary;
final Widget? trailing;
const PlaylistTile(this.playlist,
{super.key, this.onSecondary, this.onTap, this.trailing});
String? get subtitle {
if (playlist!.user == null ||
playlist!.user!.name == null ||
playlist!.user!.name == '' ||
playlist!.user!.id == DeezerAPI.instance.userId) {
if (playlist!.trackCount == null) return '';
return '${playlist!.trackCount} ${'Tracks'.i18n}';
}
return playlist!.user!.name;
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onSecondaryTapUp: onSecondary,
child: ListTile(
title: Text(
playlist!.title!,
maxLines: 1,
),
subtitle: Text(
subtitle!,
maxLines: 1,
),
leading: CachedImage(
url: playlist!.image!.thumb,
width: 48,
rounded: true,
),
onTap: onTap,
onLongPress: normalizeSecondary(onSecondary),
trailing: trailing,
),
);
}
}
class ArtistHorizontalTile extends StatelessWidget {
final Artist? artist;
final void Function()? onTap;
final void Function()? onHold;
final Widget? trailing;
const ArtistHorizontalTile(this.artist,
{super.key, this.onHold, this.onTap, this.trailing});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: ListTile(
title: Text(
artist!.name!,
maxLines: 1,
),
leading: CircleAvatar(
backgroundImage: CachedNetworkImageProvider(artist!.picture!.thumb,
cacheManager: cacheManager)),
onTap: onTap,
onLongPress: onHold,
trailing: trailing,
),
);
}
}
class PlaylistCardTile extends StatelessWidget {
final Playlist? playlist;
final VoidCallback? onTap;
final SecondaryTapCallback? onSecondary;
const PlaylistCardTile(this.playlist,
{super.key, this.onTap, this.onSecondary});
@override
Widget build(BuildContext context) {
return GestureDetector(
onSecondaryTapUp: onSecondary,
child: SizedBox(
height: 180.0,
child: InkWell(
onTap: onTap,
onLongPress: normalizeSecondary(onSecondary),
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(8.0),
child: Stack(
children: [
CachedImage(
url: playlist!.image!.thumb,
width: 128.0,
height: 128.0,
rounded: true,
),
Positioned(
bottom: 8.0,
left: 8.0,
child: PlayItemButton(
onTap: () async {
final Playlist fullPlaylist = await DeezerAPI
.instance
.fullPlaylist(playlist!.id);
await playerHelper.playFromPlaylist(fullPlaylist);
},
))
],
),
),
const SizedBox(height: 2.0),
SizedBox(
width: 144,
child: Text(
playlist!.title!,
maxLines: 1,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14.0),
),
),
const SizedBox(
height: 4.0,
)
],
),
)),
);
}
}
class PlayItemButton extends StatelessWidget {
final FutureOr<void> Function() onTap;
final double size;
final bool filled;
const PlayItemButton({
required this.onTap,
this.size = 32.0,
this.filled = true,
super.key,
});
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: size,
child: DecoratedBox(
decoration: filled
? const BoxDecoration(
shape: BoxShape.circle, color: Colors.white)
: const BoxDecoration(),
child: Center(
child: AwaitingButton(
onTap: onTap,
child: Icon(
Icons.play_arrow,
color: filled ? Colors.black : Colors.white,
shadows: filled
? null
: const [
Shadow(blurRadius: 2.0, color: Colors.black)
],
size: size / 1.5,
)))));
}
}
class AwaitingButton extends StatefulWidget {
final FutureOr<void> Function() onTap;
final double size;
final Widget child;
const AwaitingButton(
{required this.onTap, required this.child, this.size = 32.0, super.key});
@override
State<AwaitingButton> createState() => _AwaitingButtonState();
}
class _AwaitingButtonState extends State<AwaitingButton> {
final _isLoading = ValueNotifier(false);
void _onTap() {
final ret = widget.onTap();
if (ret is Future) {
_isLoading.value = true;
ret.whenComplete(() => _isLoading.value = false);
}
}
@override
void dispose() {
_isLoading.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: _isLoading,
child: InkWell(
onTap: _onTap,
child: widget.child,
),
builder: (context, isLoading, child) => isLoading
? SizedBox.square(
dimension: widget.size / 2,
child: const CircularProgressIndicator(
strokeWidth: 2.0,
color: Colors.black,
),
)
: child!);
}
}
class SmartTrackListTile extends StatefulWidget {
final SmartTrackList? smartTrackList;
final FutureOr<void> Function()? onTap;
final void Function()? onHold;
final double size;
const SmartTrackListTile(this.smartTrackList,
{super.key, this.onHold, this.onTap, this.size = 128.0});
@override
State<SmartTrackListTile> createState() => _SmartTrackListTileState();
}
class _SmartTrackListTileState extends State<SmartTrackListTile> {
final _isLoading = ValueNotifier<bool>(false);
@override
void dispose() {
_isLoading.dispose();
super.dispose();
}
void _onTap() {
final future = widget.onTap?.call();
if (future is Future) {
_isLoading.value = true;
future.whenComplete(() => _isLoading.value = false);
}
}
Widget buildTrackTileCover(List<DeezerImageDetails> covers) {
if (covers.length == 4) {
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
child: SizedBox.square(
dimension: widget.size,
child: Column(
children: [
Expanded(
child: Row(children: [
...[covers[0], covers[1]].map((e) => CachedImage(
url: e.thumb,
))
]),
),
Expanded(
child: Row(children: [
...[covers[2], covers[3]].map((e) => CachedImage(
url: e.thumb,
))
]),
),
],
),
),
);
// return GridView(
// gridDelegate:
// SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2),
// primary: false,
// physics: NeverScrollableScrollPhysics(),
// children: [...covers.map((e) => CachedImage(url: e.thumb))],
// );
}
if (widget.smartTrackList?.id == 'flow') {
return Material(
elevation: 2.0,
shape: const CircleBorder(),
color: Theme.of(context).colorScheme.onInverseSurface,
child: CachedImage(
width: widget.size,
height: widget.size,
url: covers[0].size(232, 232, id: 'none', num: 80, format: 'png'),
rounded: false,
circular: true,
),
);
}
return CachedImage(
width: widget.size,
height: widget.size,
url: covers[0].full,
rounded: true,
circular: false,
);
}
@override
Widget build(BuildContext context) {
return InkWell(
onTap: _onTap,
onLongPress: widget.onHold,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(8.0),
child: SizedBox.square(
dimension: widget.size,
child: Stack(
children: [
buildTrackTileCover(widget.smartTrackList!.cover!),
if (widget.smartTrackList?.id != 'flow')
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0, vertical: 6.0),
child: Text(
widget.smartTrackList!.title!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16.0,
shadows: [
Shadow(
offset: Offset(1, 1),
blurRadius: 2,
color: Colors.black)
],
color: Colors.white),
),
),
if (widget.smartTrackList?.id != 'flow')
Center(
child: SizedBox.square(
dimension: 32.0,
child: DecoratedBox(
decoration: const BoxDecoration(
shape: BoxShape.circle, color: Colors.white),
child: Center(
child: ValueListenableBuilder<bool>(
valueListenable: _isLoading,
builder: (context, isLoading, _) {
if (isLoading) {
return const SizedBox.square(
dimension: 16.0,
child: CircularProgressIndicator(
color: Colors.black,
strokeWidth: 2.0,
));
}
return const Icon(
Icons.play_arrow,
color: Colors.black,
size: 24.0,
);
}),
),
))),
],
),
),
),
SizedBox(
width: widget.size,
child: Text(
widget.smartTrackList!.subtitle!,
maxLines: 3,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14.0),
),
),
const SizedBox(height: 8.0)
],
),
);
}
}
class AlbumCard extends StatelessWidget {
final Album album;
final void Function()? onTap;
final SecondaryTapCallback? onSecondary;
const AlbumCard(this.album, {super.key, this.onTap, this.onSecondary});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
onLongPress: normalizeSecondary(onSecondary),
onSecondaryTapUp: onSecondary,
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(8.0),
child: Stack(
children: [
CachedImage(
width: 128.0,
height: 128.0,
url: album.art!.thumb,
rounded: true),
Positioned(
bottom: 8.0,
left: 8.0,
child: PlayItemButton(
onTap: () async {
final fullAlbum =
await DeezerAPI.instance.album(album.id);
await playerHelper.playFromAlbum(fullAlbum);
},
),
)
],
),
),
SizedBox(
width: 144.0,
child: Text(
album.title!,
maxLines: 1,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14.0),
),
),
const SizedBox(height: 4.0),
SizedBox(
width: 144.0,
child: Text(
album.artistString,
maxLines: 1,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.0,
color: (Theme.of(context).brightness == Brightness.light)
? Colors.grey[800]
: Colors.white70),
),
),
const SizedBox(height: 8.0)
],
),
);
}
}
class ChannelTile extends StatelessWidget {
final DeezerChannel channel;
final Function? onTap;
const ChannelTile(this.channel, {super.key, this.onTap});
Color _textColor() {
double luminance = channel.backgroundColor.computeLuminance();
return (luminance > 0.5) ? Colors.black : Colors.white;
}
@override
Widget build(BuildContext context) {
final Widget child;
if (channel.logo != null) {
child = Padding(
padding: const EdgeInsets.all(8.0),
child: CachedNetworkImage(
cacheKey: channel.logo!.md5,
cacheManager: cacheManager,
height: 52.0,
imageUrl:
channel.logo!.size(52, 0, num: 100, id: 'none', format: 'png')),
);
} else {
child = Text(
channel.title!,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
color: channel.picture == null ? _textColor() : Colors.white),
);
}
return SizedBox(
width: 150,
height: 75,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
color: channel.picture == null ? channel.backgroundColor : null,
image: channel.picture == null
? null
: DecorationImage(
fit: BoxFit.cover,
image: CachedNetworkImageProvider(
channel.picture!.size(134, 264),
cacheManager: cacheManager,
cacheKey: channel.picture!.md5))),
child: Material(
color: Colors.transparent,
clipBehavior: Clip.hardEdge,
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
child: InkWell(
focusColor: Colors.black45, // give better visibility
onTap: onTap as void Function()?,
child: Center(child: child)),
),
),
);
}
}
class ShowCard extends StatelessWidget {
final Show? show;
final VoidCallback? onTap;
final VoidCallback? onHold;
const ShowCard(this.show, {super.key, this.onTap, this.onHold});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
onLongPress: onHold,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: CachedImage(
url: show!.art!.thumb,
width: 128.0,
height: 128.0,
rounded: true,
),
),
SizedBox(
width: 144.0,
child: Text(
show!.name!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 14.0),
),
),
],
),
);
}
}
class ShowTile extends StatelessWidget {
final Show show;
final Function? onTap;
final Function? onHold;
const ShowTile(this.show, {super.key, this.onTap, this.onHold});
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(
show.name!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
show.description!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: onTap as void Function()?,
onLongPress: onHold as void Function()?,
leading: CachedImage(
url: show.art!.thumb,
width: 48,
),
);
}
}
class ShowEpisodeTile extends StatelessWidget {
final ShowEpisode episode;
final VoidCallback? onTap;
final SecondaryTapCallback? onSecondary;
final Widget? trailing;
const ShowEpisodeTile(this.episode,
{super.key, this.onTap, this.onSecondary, this.trailing});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
onLongPress: normalizeSecondary(onSecondary),
onSecondaryTapUp: onSecondary,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text(episode.title!, maxLines: 2),
trailing: trailing,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
episode.description!,
maxLines: 10,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Theme.of(context)
.textTheme
.titleMedium!
.color!
.withOpacity(0.9)),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8.0, 0, 0),
child: Row(
mainAxisSize: MainAxisSize.max,
children: [
Text(
'${episode.publishedDate} | ${episode.durationString}',
textAlign: TextAlign.left,
style: TextStyle(
fontSize: 12.0,
fontWeight: FontWeight.bold,
color: Theme.of(context)
.textTheme
.titleMedium!
.color!
.withOpacity(0.6)),
),
],
),
),
const Divider(),
],
),
);
}
}