freezer/lib/ui/tiles.dart
Pato05 87c9733f51
add build script for linux
fix audio service stop on android
getTrack backend improvements
get new track token when expired
move shuffle button into LibraryPlaylists as FAB
move favoriteButton next to track title
move lyrics button on top of album art
search: fix chips, and remove checkbox when selected
2024-02-19 00:49:32 +01:00

878 lines
26 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/icons.dart';
import 'package:freezer/main.dart';
import 'package:freezer/translations.i18n.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 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: CachedImage(
url: artUri,
width: 48.0,
height: 48.0,
),
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.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.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;
const PlayItemButton({required this.onTap, this.size = 32.0, super.key});
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: size,
child: DecoratedBox(
decoration: const BoxDecoration(
shape: BoxShape.circle, color: Colors.white),
child: Center(
child: AwaitingButton(
onTap: onTap,
child: Icon(
Icons.play_arrow,
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.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(),
],
),
);
}
}