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( 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( 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: [ 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: [ 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 Function() onTap; final double size; const PlayItemButton({required this.onTap, this.size = 32.0, Key? key}) : super(key: key); @override State createState() => _PlayItemButtonState(); } class _PlayItemButtonState extends State { 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( 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 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 createState() => _SmartTrackListTileState(); } class _SmartTrackListTileState extends State { final _isLoading = ValueNotifier(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 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: [ 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( 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: [ 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(), ], ), ); } }