758 lines
22 KiB
Dart
758 lines
22 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.dart';
|
|
import 'package:freezer/translations.i18n.dart';
|
|
|
|
import '../api/definitions.dart';
|
|
import 'cached_image.dart';
|
|
|
|
import 'dart:async';
|
|
|
|
class TrackTile extends StatelessWidget {
|
|
final Track track;
|
|
final void Function()? onTap;
|
|
final void Function()? onHold;
|
|
final Widget? trailing;
|
|
|
|
TrackTile(this.track, {this.onTap, this.onHold, this.trailing, Key? key})
|
|
: super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListTile(
|
|
title: StreamBuilder<MediaItem?>(
|
|
stream: audioHandler.mediaItem,
|
|
builder: (context, snapshot) {
|
|
final bool isHighlighted;
|
|
final mediaItem = snapshot.data;
|
|
if (!snapshot.hasData || snapshot.data == null)
|
|
isHighlighted = false;
|
|
else
|
|
isHighlighted = mediaItem!.id == track.id;
|
|
return Text(
|
|
track.title!,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.clip,
|
|
style: TextStyle(
|
|
color: isHighlighted
|
|
? Theme.of(context).colorScheme.primary
|
|
: null),
|
|
);
|
|
}),
|
|
subtitle: Text(
|
|
track.artistString,
|
|
maxLines: 1,
|
|
),
|
|
leading: CachedImage(
|
|
url: track.albumArt!.thumb,
|
|
width: 48.0,
|
|
height: 48.0,
|
|
),
|
|
onTap: onTap,
|
|
onLongPress: onHold,
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
FutureBuilder<bool>(
|
|
future: downloadManager.checkOffline(track: track),
|
|
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 (track.explicit ?? false)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 2.0),
|
|
child: Text(
|
|
'E',
|
|
style: TextStyle(color: Colors.red),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 42.0,
|
|
child: Text(
|
|
track.durationString,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
if (trailing != null) trailing!
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class AlbumTile extends StatelessWidget {
|
|
final Album? album;
|
|
final void Function()? onTap;
|
|
final void Function()? onHold;
|
|
final Widget? trailing;
|
|
|
|
AlbumTile(this.album, {this.onTap, this.onHold, this.trailing});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListTile(
|
|
title: Text(
|
|
album!.title!,
|
|
maxLines: 1,
|
|
),
|
|
subtitle: Text(
|
|
album!.artistString,
|
|
maxLines: 1,
|
|
),
|
|
leading: CachedImage(
|
|
url: album!.art!.thumb,
|
|
width: 48,
|
|
),
|
|
onTap: onTap,
|
|
onLongPress: onHold,
|
|
trailing: trailing,
|
|
);
|
|
}
|
|
}
|
|
|
|
class ArtistTile extends StatelessWidget {
|
|
final Artist? artist;
|
|
final void Function()? onTap;
|
|
final void Function()? onHold;
|
|
|
|
ArtistTile(this.artist, {this.onTap, this.onHold});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return InkWell(
|
|
borderRadius: BorderRadius.all(Radius.circular(4.0)),
|
|
onTap: onTap,
|
|
onLongPress: onHold,
|
|
child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
|
const SizedBox(height: 4),
|
|
CachedImage(
|
|
url: artist!.picture!.thumb,
|
|
circular: true,
|
|
width: 100,
|
|
),
|
|
const SizedBox(height: 8),
|
|
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 void Function()? onHold;
|
|
final Widget? trailing;
|
|
|
|
PlaylistTile(this.playlist, {this.onHold, 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 ListTile(
|
|
title: Text(
|
|
playlist!.title!,
|
|
maxLines: 1,
|
|
),
|
|
subtitle: Text(
|
|
subtitle!,
|
|
maxLines: 1,
|
|
),
|
|
leading: CachedImage(
|
|
url: playlist!.image!.thumb,
|
|
width: 48,
|
|
),
|
|
onTap: onTap,
|
|
onLongPress: onHold,
|
|
trailing: trailing,
|
|
);
|
|
}
|
|
}
|
|
|
|
class ArtistHorizontalTile extends StatelessWidget {
|
|
final Artist? artist;
|
|
final void Function()? onTap;
|
|
final void Function()? onHold;
|
|
final Widget? trailing;
|
|
|
|
ArtistHorizontalTile(this.artist, {this.onHold, this.onTap, this.trailing});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 2.0),
|
|
child: ListTile(
|
|
title: Text(
|
|
artist!.name!,
|
|
maxLines: 1,
|
|
),
|
|
leading: CircleAvatar(
|
|
backgroundImage:
|
|
CachedNetworkImageProvider(artist!.picture!.thumb)),
|
|
onTap: onTap,
|
|
onLongPress: onHold,
|
|
trailing: trailing,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class PlaylistCardTile extends StatelessWidget {
|
|
final Playlist? playlist;
|
|
final Function? onTap;
|
|
final Function? onHold;
|
|
PlaylistCardTile(this.playlist, {this.onTap, this.onHold});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox(
|
|
height: 180.0,
|
|
child: InkWell(
|
|
onTap: onTap as void Function()?,
|
|
onLongPress: onHold as void Function()?,
|
|
child: Column(
|
|
children: <Widget>[
|
|
Padding(
|
|
padding: 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: 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: 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: 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,
|
|
{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))],
|
|
// );
|
|
}
|
|
return CachedImage(
|
|
width: widget.size,
|
|
height: widget.size,
|
|
url: covers[0].full,
|
|
rounded: widget.smartTrackList?.id != 'flow',
|
|
circular: widget.smartTrackList?.id == 'flow',
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return InkWell(
|
|
onTap: _onTap,
|
|
onLongPress: widget.onHold,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
Padding(
|
|
padding: EdgeInsets.all(8.0),
|
|
child: SizedBox.square(
|
|
dimension: widget.size,
|
|
child: Stack(
|
|
children: [
|
|
buildTrackTileCover(widget.smartTrackList!.cover!),
|
|
if (widget.smartTrackList?.id != 'flow')
|
|
Padding(
|
|
padding:
|
|
EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0),
|
|
child: Text(
|
|
widget.smartTrackList!.title!,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 16.0,
|
|
shadows: [
|
|
Shadow(
|
|
offset: Offset(1, 1),
|
|
blurRadius: 2,
|
|
color: Colors.black)
|
|
],
|
|
color: Colors.white),
|
|
),
|
|
),
|
|
Center(
|
|
child: SizedBox.square(
|
|
dimension: 32.0,
|
|
child: DecoratedBox(
|
|
decoration: 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: TextStyle(fontSize: 14.0),
|
|
),
|
|
),
|
|
const SizedBox(height: 8.0)
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class AlbumCard extends StatelessWidget {
|
|
final Album album;
|
|
final void Function()? onTap;
|
|
final void Function()? onHold;
|
|
|
|
AlbumCard(this.album, {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(
|
|
child: PlayItemButton(
|
|
onTap: () async {
|
|
final fullAlbum = await deezerAPI.album(album.id);
|
|
await playerHelper.playFromAlbum(fullAlbum);
|
|
},
|
|
),
|
|
bottom: 8.0,
|
|
left: 8.0,
|
|
)
|
|
],
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 144.0,
|
|
child: Text(
|
|
album.title!,
|
|
maxLines: 1,
|
|
textAlign: TextAlign.center,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: 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;
|
|
ChannelTile(this.channel, {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,
|
|
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),
|
|
cacheKey: channel.picture!.md5))),
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
clipBehavior: Clip.hardEdge,
|
|
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
|
|
child: InkWell(
|
|
onTap: this.onTap as void Function()?,
|
|
child: Center(child: child)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ShowCard extends StatelessWidget {
|
|
final Show? show;
|
|
final Function? onTap;
|
|
final Function? onHold;
|
|
|
|
ShowCard(this.show, {this.onTap, this.onHold});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
child: InkWell(
|
|
onTap: onTap as void Function()?,
|
|
onLongPress: onHold as void Function()?,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: EdgeInsets.all(8.0),
|
|
child: CachedImage(
|
|
url: show!.art!.thumb,
|
|
width: 128.0,
|
|
height: 128.0,
|
|
rounded: true,
|
|
),
|
|
),
|
|
Container(
|
|
width: 144.0,
|
|
child: Text(
|
|
show!.name!,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(fontSize: 14.0),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ShowTile extends StatelessWidget {
|
|
final Show show;
|
|
final Function? onTap;
|
|
final Function? onHold;
|
|
|
|
ShowTile(this.show, {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 Function? onTap;
|
|
final Function? onHold;
|
|
final Widget? trailing;
|
|
|
|
ShowEpisodeTile(this.episode, {this.onTap, this.onHold, this.trailing});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return InkWell(
|
|
onLongPress: onHold as void Function()?,
|
|
onTap: onTap as void Function()?,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ListTile(
|
|
title: Text(episode.title!, maxLines: 2),
|
|
trailing: trailing,
|
|
),
|
|
Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 16.0),
|
|
child: Text(
|
|
episode.description!,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: Theme.of(context)
|
|
.textTheme
|
|
.titleMedium!
|
|
.color!
|
|
.withOpacity(0.9)),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: 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)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Divider(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|