freezer/lib/ui/tiles.dart

867 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:fluttericon/octicons_icons.dart';
import 'package:freezer/api/deezer.dart';
import 'package:freezer/api/download.dart';
import 'package:freezer/api/player/audio_handler.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(
Octicons.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 StatefulWidget {
final FutureOr<void> Function() onTap;
final double size;
const PlayItemButton({required this.onTap, this.size = 32.0, Key? key})
: super(key: key);
@override
State<PlayItemButton> createState() => _PlayItemButtonState();
}
class _PlayItemButtonState extends State<PlayItemButton> {
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 SizedBox.square(
dimension: widget.size,
child: DecoratedBox(
decoration:
const BoxDecoration(shape: BoxShape.circle, color: Colors.white),
child: Center(
child: ValueListenableBuilder<bool>(
valueListenable: _isLoading,
child: InkWell(
onTap: _onTap,
child: Icon(
Icons.play_arrow,
color: Colors.black,
size: widget.size / 1.5,
),
),
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: () => onSecondary?.call(null),
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(),
],
),
);
}
}