freezer/lib/ui/tiles.dart
Pato05 2862c9ec05
remove browser login for desktop
restore translations functionality
make scrollViews handle mouse pointers like touch, so that pull to refresh functionality is available
exit app if opening cache or settings fails (another instance running)
remove draggable_scrollbar and use builtin widget instead
fix email login
better way to manage lyrics (less updates and lookups in the lyrics List)
fix player_screen on mobile (too big -> just average :))
right click: use TapUp events instead
desktop: show context menu on triple dots button also
avoid showing connection error if the homepage is cached and available offline
i'm probably forgetting something idk
2023-10-25 00:32:28 +02:00

865 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,
Key? key,
}) : super(key: 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,
),
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 void Function()? onHold;
const AlbumCard(this.album, {super.key, this.onTap, this.onHold});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
onLongPress: onHold,
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(),
],
),
);
}
}