Pato05
2862c9ec05
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
1289 lines
47 KiB
Dart
1289 lines
47 KiB
Dart
import 'dart:async';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:fluttericon/font_awesome5_icons.dart';
|
|
import 'package:freezer/api/cache.dart';
|
|
import 'package:freezer/api/deezer.dart';
|
|
import 'package:freezer/api/download.dart';
|
|
import 'package:freezer/api/player/audio_handler.dart';
|
|
import 'package:freezer/ui/elements.dart';
|
|
import 'package:freezer/ui/error.dart';
|
|
import 'package:freezer/ui/search.dart';
|
|
import 'package:freezer/translations.i18n.dart';
|
|
|
|
import '../api/definitions.dart';
|
|
import 'cached_image.dart';
|
|
import 'tiles.dart';
|
|
import 'menu.dart';
|
|
|
|
class AlbumDetails extends StatefulWidget {
|
|
final Album? album;
|
|
const AlbumDetails(this.album, {Key? key}) : super(key: key);
|
|
|
|
@override
|
|
State<AlbumDetails> createState() => _AlbumDetailsState();
|
|
}
|
|
|
|
class _AlbumDetailsState extends State<AlbumDetails> {
|
|
Album? album;
|
|
bool _loading = true;
|
|
bool _error = false;
|
|
|
|
Future _loadAlbum() async {
|
|
//Get album from API, if doesn't have tracks
|
|
if (album!.tracks == null || album!.tracks!.isEmpty) {
|
|
try {
|
|
Album a = await deezerAPI.album(album!.id);
|
|
//Preserve library
|
|
a.library = album!.library;
|
|
setState(() => album = a);
|
|
} catch (e) {
|
|
setState(() => _error = true);
|
|
}
|
|
}
|
|
setState(() => _loading = false);
|
|
}
|
|
|
|
//Get count of CDs in album
|
|
int? get cdCount {
|
|
int? c = 1;
|
|
for (Track? t in album!.tracks!) {
|
|
if ((t!.diskNumber ?? 1) > c!) c = t.diskNumber;
|
|
}
|
|
return c;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
album = widget.album;
|
|
_loadAlbum();
|
|
super.initState();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(album!.title ?? ''),
|
|
),
|
|
body: _error
|
|
? const ErrorScreen()
|
|
: _loading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: ListView(
|
|
children: <Widget>[
|
|
//Album art, title, artists
|
|
Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
const SizedBox(height: 8.0),
|
|
ConstrainedBox(
|
|
constraints: BoxConstraints.loose(
|
|
MediaQuery.of(context).size / 2.5),
|
|
child: ZoomableImage(
|
|
url: album!.art!.full,
|
|
rounded: true,
|
|
),
|
|
),
|
|
const SizedBox(height: 8.0),
|
|
Text(
|
|
album!.title!,
|
|
textAlign: TextAlign.center,
|
|
overflow: TextOverflow.ellipsis,
|
|
maxLines: 2,
|
|
style: const TextStyle(
|
|
fontSize: 20.0, fontWeight: FontWeight.bold),
|
|
),
|
|
Text(
|
|
album!.artistString,
|
|
textAlign: TextAlign.center,
|
|
overflow: TextOverflow.ellipsis,
|
|
maxLines: 2,
|
|
style: TextStyle(
|
|
fontSize: 16.0,
|
|
color: Theme.of(context).primaryColor),
|
|
),
|
|
const SizedBox(height: 4.0),
|
|
if (album!.releaseDate != null &&
|
|
album!.releaseDate!.length >= 4)
|
|
Text(
|
|
album!.releaseDate!,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 12.0,
|
|
color: Theme.of(context).disabledColor),
|
|
),
|
|
const SizedBox(height: 8.0),
|
|
],
|
|
),
|
|
const FreezerDivider(),
|
|
//Details
|
|
Row(
|
|
mainAxisSize: MainAxisSize.max,
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: <Widget>[
|
|
Row(
|
|
children: <Widget>[
|
|
Icon(
|
|
Icons.audiotrack,
|
|
size: 32.0,
|
|
semanticLabel: "Tracks".i18n,
|
|
),
|
|
const SizedBox(
|
|
width: 8.0,
|
|
height: 42.0,
|
|
), //Height to adjust card height
|
|
Text(
|
|
album!.tracks!.length.toString(),
|
|
style: const TextStyle(fontSize: 16.0),
|
|
)
|
|
],
|
|
),
|
|
Row(
|
|
children: <Widget>[
|
|
Icon(
|
|
Icons.timelapse,
|
|
size: 32.0,
|
|
semanticLabel: "Duration".i18n,
|
|
),
|
|
Container(
|
|
width: 8.0,
|
|
),
|
|
Text(
|
|
album!.durationString,
|
|
style: const TextStyle(fontSize: 16.0),
|
|
)
|
|
],
|
|
),
|
|
Row(
|
|
children: <Widget>[
|
|
Icon(Icons.people,
|
|
size: 32.0, semanticLabel: "Fans".i18n),
|
|
Container(
|
|
width: 8.0,
|
|
),
|
|
Text(
|
|
album!.fansString,
|
|
style: const TextStyle(fontSize: 16.0),
|
|
)
|
|
],
|
|
),
|
|
],
|
|
),
|
|
const FreezerDivider(),
|
|
//Options (offline, download...)
|
|
Row(
|
|
mainAxisSize: MainAxisSize.max,
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
children: <Widget>[
|
|
TextButton.icon(
|
|
icon: Icon((album!.library ?? false)
|
|
? Icons.favorite
|
|
: Icons.favorite_border),
|
|
label: Text('Library'.i18n),
|
|
onPressed: () async {
|
|
//Add to library
|
|
if (!album!.library!) {
|
|
await deezerAPI.addFavoriteAlbum(album!.id);
|
|
ScaffoldMessenger.of(context)
|
|
.snack('Added to library'.i18n);
|
|
setState(() => album!.library = true);
|
|
return;
|
|
}
|
|
//Remove
|
|
await deezerAPI.removeAlbum(album!.id);
|
|
ScaffoldMessenger.of(context)
|
|
.snack('Album removed from library!'.i18n);
|
|
setState(() => album!.library = false);
|
|
},
|
|
),
|
|
MakeAlbumOffline(album: album),
|
|
TextButton.icon(
|
|
icon: const Icon(Icons.file_download),
|
|
label: Text('Download'.i18n),
|
|
onPressed: () async {
|
|
if (await downloadManager.addOfflineAlbum(album,
|
|
private: false, context: context) !=
|
|
false) {
|
|
MenuSheet(context).showDownloadStartedToast();
|
|
}
|
|
},
|
|
)
|
|
],
|
|
),
|
|
const FreezerDivider(),
|
|
...List.generate(cdCount!, (cdi) {
|
|
List<Track?> tracks = album!.tracks!
|
|
.where((t) => (t.diskNumber ?? 1) == cdi + 1)
|
|
.toList();
|
|
return Column(
|
|
children: [
|
|
Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(vertical: 4.0),
|
|
child: Text(
|
|
'${'Disk'.i18n.toUpperCase()} ${cdi + 1}',
|
|
style: const TextStyle(
|
|
fontSize: 12.0,
|
|
fontWeight: FontWeight.w300),
|
|
),
|
|
),
|
|
...List.generate(
|
|
tracks.length,
|
|
(i) =>
|
|
TrackTile.fromTrack(tracks[i]!, onTap: () {
|
|
playerHelper.playFromAlbum(
|
|
album!, tracks[i]!.id);
|
|
}, onSecondary: (details) {
|
|
MenuSheet m = MenuSheet(context);
|
|
m.defaultTrackMenu(tracks[i]!,
|
|
details: details);
|
|
}))
|
|
],
|
|
);
|
|
}),
|
|
],
|
|
));
|
|
}
|
|
}
|
|
|
|
class MakeAlbumOffline extends StatefulWidget {
|
|
final Album? album;
|
|
const MakeAlbumOffline({Key? key, this.album}) : super(key: key);
|
|
|
|
@override
|
|
State<MakeAlbumOffline> createState() => _MakeAlbumOfflineState();
|
|
}
|
|
|
|
class _MakeAlbumOfflineState extends State<MakeAlbumOffline> {
|
|
bool _offline = false;
|
|
|
|
@override
|
|
void initState() {
|
|
downloadManager.checkOffline(album: widget.album).then((v) {
|
|
setState(() {
|
|
_offline = v;
|
|
});
|
|
});
|
|
super.initState();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
children: <Widget>[
|
|
Switch(
|
|
value: _offline,
|
|
onChanged: (v) async {
|
|
if (v) {
|
|
//Add to offline
|
|
await deezerAPI.addFavoriteAlbum(widget.album!.id);
|
|
downloadManager.addOfflineAlbum(widget.album, private: true);
|
|
MenuSheet(context).showDownloadStartedToast();
|
|
setState(() {
|
|
_offline = true;
|
|
});
|
|
return;
|
|
}
|
|
downloadManager.removeOfflineAlbum(widget.album!.id);
|
|
ScaffoldMessenger.of(context)
|
|
.snack("Removed album from offline!".i18n);
|
|
setState(() {
|
|
_offline = false;
|
|
});
|
|
},
|
|
),
|
|
const SizedBox(width: 4.0),
|
|
Text(
|
|
'Offline'.i18n,
|
|
style: const TextStyle(fontSize: 16),
|
|
)
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class ArtistDetails extends StatefulWidget {
|
|
final Artist artist;
|
|
|
|
const ArtistDetails(this.artist, {super.key});
|
|
|
|
@override
|
|
State<ArtistDetails> createState() => _ArtistDetailsState();
|
|
}
|
|
|
|
class _ArtistDetailsState extends State<ArtistDetails> {
|
|
late final Future<Artist> _future;
|
|
|
|
@override
|
|
void initState() {
|
|
_future = _loadArtist(widget.artist);
|
|
super.initState();
|
|
}
|
|
|
|
Future<Artist> _loadArtist(Artist artist) async {
|
|
//Load artist from api if no albums
|
|
if ((artist.albums ?? []).isEmpty) {
|
|
return await deezerAPI.artist(artist.id);
|
|
}
|
|
|
|
return artist;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: Text(widget.artist.name ?? '')),
|
|
body: FutureBuilder<Artist>(
|
|
future: _future,
|
|
builder: (BuildContext context, snapshot) {
|
|
//Error / not done
|
|
if (snapshot.hasError) return const ErrorScreen();
|
|
if (snapshot.connectionState != ConnectionState.done) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
final artist = snapshot.data!;
|
|
|
|
return ListView(
|
|
children: <Widget>[
|
|
const SizedBox(height: 4.0),
|
|
Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxHeight: MediaQuery.of(context).size.height / 3),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.start,
|
|
children: <Widget>[
|
|
Flexible(
|
|
child: ZoomableImage(
|
|
url: artist.picture!.full,
|
|
rounded: true,
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: min(
|
|
MediaQuery.of(context).size.width / 16, 60.0)),
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.max,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Text(
|
|
artist.name!,
|
|
overflow: TextOverflow.ellipsis,
|
|
maxLines: 4,
|
|
style: const TextStyle(
|
|
fontSize: 24.0, fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 8.0),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
Icon(
|
|
Icons.people,
|
|
size: 32.0,
|
|
semanticLabel: "Fans".i18n,
|
|
),
|
|
const SizedBox(width: 8.0),
|
|
Text(
|
|
artist.fansString,
|
|
style: const TextStyle(fontSize: 16),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4.0),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
Icon(
|
|
Icons.album,
|
|
size: 32.0,
|
|
semanticLabel: "Albums".i18n,
|
|
),
|
|
const SizedBox(width: 8.0),
|
|
Text(
|
|
widget.artist.albumCount.toString(),
|
|
style: const TextStyle(fontSize: 16),
|
|
)
|
|
],
|
|
)
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const FreezerDivider(),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.max,
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
children: <Widget>[
|
|
TextButton.icon(
|
|
icon: const Icon(Icons.favorite),
|
|
label: Text('Library'.i18n),
|
|
onPressed: () async {
|
|
await deezerAPI.addFavoriteArtist(widget.artist.id);
|
|
ScaffoldMessenger.of(context)
|
|
.snack('Added to library'.i18n);
|
|
},
|
|
),
|
|
if ((artist.radio ?? false))
|
|
TextButton.icon(
|
|
icon: const Icon(Icons.radio),
|
|
label: Text('Radio'.i18n),
|
|
onPressed: () async {
|
|
List<Track> tracks =
|
|
(await deezerAPI.smartRadio(artist.id))!;
|
|
playerHelper.playFromTrackList(
|
|
tracks,
|
|
tracks[0].id,
|
|
QueueSource(
|
|
id: artist.id,
|
|
text: '${'Radio'.i18n} ${artist.name}',
|
|
source: 'smartradio'));
|
|
},
|
|
)
|
|
],
|
|
),
|
|
const FreezerDivider(),
|
|
const SizedBox(height: 12.0),
|
|
//Highlight
|
|
if (artist.highlight != null)
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16.0, vertical: 2.0),
|
|
child: Text(
|
|
artist.highlight!.title!,
|
|
textAlign: TextAlign.left,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold, fontSize: 20.0),
|
|
),
|
|
),
|
|
if (artist.highlight!.type == ArtistHighlightType.ALBUM)
|
|
AlbumTile(
|
|
artist.highlight!.data,
|
|
onTap: () {
|
|
Navigator.of(context).pushRoute(
|
|
builder: (context) =>
|
|
AlbumDetails(artist.highlight!.data));
|
|
},
|
|
),
|
|
const SizedBox(height: 8.0)
|
|
],
|
|
),
|
|
//Top tracks
|
|
Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 2.0),
|
|
child: Text(
|
|
'Top Tracks'.i18n,
|
|
textAlign: TextAlign.left,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold, fontSize: 20.0),
|
|
),
|
|
),
|
|
const SizedBox(height: 4.0),
|
|
...List.generate(5, (i) {
|
|
if (artist.topTracks!.length <= i) {
|
|
return const SizedBox(height: 0.0, width: 0.0);
|
|
}
|
|
Track t = artist.topTracks![i];
|
|
return TrackTile.fromTrack(
|
|
t,
|
|
onTap: () {
|
|
playerHelper.playFromTopTracks(
|
|
artist.topTracks!, t.id, artist);
|
|
},
|
|
onSecondary: (details) {
|
|
MenuSheet mi = MenuSheet(context);
|
|
mi.defaultTrackMenu(t, details: details);
|
|
},
|
|
);
|
|
}),
|
|
ListTile(
|
|
title: Text('Show more tracks'.i18n),
|
|
onTap: () {
|
|
Navigator.of(context).pushRoute(
|
|
builder: (context) => TrackListScreen(
|
|
artist.topTracks,
|
|
QueueSource(
|
|
id: artist.id,
|
|
text: '${'Top'.i18n}${artist.name}',
|
|
source: 'topTracks')));
|
|
}),
|
|
const FreezerDivider(),
|
|
//Albums
|
|
Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
|
child: Text(
|
|
'Top Albums'.i18n,
|
|
textAlign: TextAlign.left,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold, fontSize: 20.0),
|
|
),
|
|
),
|
|
...List.generate(
|
|
artist.albums!.length > 10 ? 11 : artist.albums!.length + 1,
|
|
(i) {
|
|
//Show discography
|
|
if (i == 10 || i == artist.albums!.length) {
|
|
return ListTile(
|
|
title: Text('Show all albums'.i18n),
|
|
onTap: () {
|
|
Navigator.of(context).pushRoute(
|
|
builder: (context) => DiscographyScreen(
|
|
artist: artist,
|
|
));
|
|
});
|
|
}
|
|
//Top albums
|
|
Album a = artist.albums![i];
|
|
return AlbumTile(
|
|
a,
|
|
onTap: () {
|
|
Navigator.of(context)
|
|
.pushRoute(builder: (context) => AlbumDetails(a));
|
|
},
|
|
onSecondary: (details) {
|
|
MenuSheet m = MenuSheet(context);
|
|
m.defaultAlbumMenu(a, details: details);
|
|
},
|
|
);
|
|
})
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class DiscographyScreen extends StatefulWidget {
|
|
final Artist? artist;
|
|
const DiscographyScreen({required this.artist, Key? key}) : super(key: key);
|
|
|
|
@override
|
|
State<DiscographyScreen> createState() => _DiscographyScreenState();
|
|
}
|
|
|
|
class _DiscographyScreenState extends State<DiscographyScreen> {
|
|
Artist? artist;
|
|
bool _loading = false;
|
|
bool _error = false;
|
|
final List<ScrollController> _controllers = [
|
|
ScrollController(),
|
|
ScrollController(),
|
|
ScrollController()
|
|
];
|
|
|
|
Future _load() async {
|
|
if (artist!.albums!.length >= artist!.albumCount! || _loading) return;
|
|
setState(() => _loading = true);
|
|
|
|
//Fetch data
|
|
List<Album>? data;
|
|
try {
|
|
data = await deezerAPI.discographyPage(artist!.id,
|
|
start: artist!.albums!.length);
|
|
} catch (e) {
|
|
setState(() {
|
|
_error = true;
|
|
_loading = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
//Save
|
|
setState(() {
|
|
artist!.albums!.addAll(data!);
|
|
_loading = false;
|
|
});
|
|
}
|
|
|
|
//Get album tile
|
|
Widget _tile(Album a) => AlbumTile(
|
|
a,
|
|
onTap: () => Navigator.of(context)
|
|
.push(MaterialPageRoute(builder: (context) => AlbumDetails(a))),
|
|
onSecondary: (details) {
|
|
MenuSheet m = MenuSheet(context);
|
|
m.defaultAlbumMenu(a, details: details);
|
|
},
|
|
);
|
|
|
|
Widget get _loadingWidget {
|
|
if (_loading) {
|
|
return const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 8.0),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [CircularProgressIndicator()],
|
|
),
|
|
);
|
|
}
|
|
//Error
|
|
if (_error) return const ErrorScreen();
|
|
//Success
|
|
return const SizedBox(width: 0.0, height: 0.0);
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
artist = widget.artist;
|
|
|
|
//Lazy loading scroll
|
|
for (var _c in _controllers) {
|
|
_c.addListener(() {
|
|
double off = _c.position.maxScrollExtent * 0.85;
|
|
if (_c.position.pixels > off) _load();
|
|
});
|
|
}
|
|
|
|
super.initState();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return DefaultTabController(
|
|
length: 3,
|
|
child: Builder(builder: (BuildContext context) {
|
|
final TabController tabController = DefaultTabController.of(context);
|
|
tabController.addListener(() {
|
|
if (!tabController.indexIsChanging) {
|
|
//Load data if empty tabs
|
|
int nSingles = artist!.albums!
|
|
.where((a) => a.type == AlbumType.SINGLE)
|
|
.length;
|
|
int nFeatures = artist!.albums!
|
|
.where((a) => a.type == AlbumType.FEATURED)
|
|
.length;
|
|
if ((nSingles == 0 || nFeatures == 0) && !_loading) _load();
|
|
}
|
|
});
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text('Discography'.i18n),
|
|
bottom: TabBar(
|
|
tabs: [
|
|
Tab(
|
|
icon: Icon(
|
|
Icons.album,
|
|
semanticLabel: "Albums".i18n,
|
|
)),
|
|
Tab(
|
|
icon: Icon(Icons.audiotrack,
|
|
semanticLabel: "Singles".i18n)),
|
|
Tab(
|
|
icon: Icon(
|
|
Icons.recent_actors,
|
|
semanticLabel: "Featured".i18n,
|
|
))
|
|
],
|
|
),
|
|
toolbarHeight: 100.0,
|
|
),
|
|
body: TabBarView(
|
|
children: [
|
|
//Albums
|
|
ListView.builder(
|
|
controller: _controllers[0],
|
|
itemCount: artist!.albums!.length + 1,
|
|
itemBuilder: (context, i) {
|
|
if (i == artist!.albums!.length) return _loadingWidget;
|
|
if (artist!.albums![i].type == AlbumType.ALBUM) {
|
|
return _tile(artist!.albums![i]);
|
|
}
|
|
return const SizedBox(width: 0.0, height: 0.0);
|
|
},
|
|
),
|
|
//Singles
|
|
ListView.builder(
|
|
controller: _controllers[1],
|
|
itemCount: artist!.albums!.length + 1,
|
|
itemBuilder: (context, i) {
|
|
if (i == artist!.albums!.length) return _loadingWidget;
|
|
if (artist!.albums![i].type == AlbumType.SINGLE) {
|
|
return _tile(artist!.albums![i]);
|
|
}
|
|
return const SizedBox(width: 0.0, height: 0.0);
|
|
},
|
|
),
|
|
//Featured
|
|
ListView.builder(
|
|
controller: _controllers[2],
|
|
itemCount: artist!.albums!.length + 1,
|
|
itemBuilder: (context, i) {
|
|
if (i == artist!.albums!.length) return _loadingWidget;
|
|
if (artist!.albums![i].type == AlbumType.FEATURED) {
|
|
return _tile(artist!.albums![i]);
|
|
}
|
|
return const SizedBox(width: 0.0, height: 0.0);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}));
|
|
}
|
|
}
|
|
|
|
class PlaylistDetails extends StatefulWidget {
|
|
final Playlist? playlist;
|
|
const PlaylistDetails(this.playlist, {Key? key}) : super(key: key);
|
|
|
|
@override
|
|
State<PlaylistDetails> createState() => _PlaylistDetailsState();
|
|
}
|
|
|
|
class _PlaylistDetailsState extends State<PlaylistDetails> {
|
|
Playlist? playlist;
|
|
bool _loading = false;
|
|
bool _error = false;
|
|
Sorting? _sort;
|
|
final ScrollController _scrollController = ScrollController();
|
|
|
|
//Get sorted playlist
|
|
List<Track> get sorted {
|
|
List<Track> tracks = List.from(playlist!.tracks ?? []);
|
|
switch (_sort!.type) {
|
|
case SortType.ALPHABETIC:
|
|
tracks.sort((a, b) => a.title!.compareTo(b.title!));
|
|
break;
|
|
case SortType.ARTIST:
|
|
tracks.sort((a, b) => a.artists![0].name!
|
|
.toLowerCase()
|
|
.compareTo(b.artists![0].name!.toLowerCase()));
|
|
break;
|
|
case SortType.DATE_ADDED:
|
|
tracks.sort((a, b) => (a.addedDate ?? 0) - (b.addedDate ?? 0));
|
|
break;
|
|
case SortType.DEFAULT:
|
|
default:
|
|
break;
|
|
}
|
|
//Reverse
|
|
if (_sort!.reverse!) return tracks.reversed.toList();
|
|
return tracks;
|
|
}
|
|
|
|
//Load tracks from api
|
|
void _load() async {
|
|
if (playlist!.tracks!.length <
|
|
(playlist!.trackCount ?? playlist!.tracks!.length) &&
|
|
!_loading) {
|
|
setState(() => _loading = true);
|
|
int pos = playlist!.tracks!.length;
|
|
//Get another page of tracks
|
|
List<Track>? tracks;
|
|
try {
|
|
tracks = await deezerAPI.playlistTracksPage(playlist!.id, pos);
|
|
} catch (e) {
|
|
setState(() => _error = true);
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
playlist!.tracks!.addAll(tracks!);
|
|
_loading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
//Load cached playlist sorting
|
|
void _restoreSort() async {
|
|
//Find index
|
|
int? index = Sorting.index(SortSourceTypes.PLAYLIST, id: playlist!.id);
|
|
if (index == null) return;
|
|
|
|
//Preload tracks
|
|
if (playlist!.tracks!.length < playlist!.trackCount!) {
|
|
playlist = await deezerAPI.fullPlaylist(playlist!.id);
|
|
}
|
|
setState(() => _sort = cache.sorts[index]);
|
|
}
|
|
|
|
Future _reverse() async {
|
|
setState(() => _sort!.reverse = !_sort!.reverse!);
|
|
//Save sorting in cache
|
|
int? index = Sorting.index(SortSourceTypes.TRACKS);
|
|
if (index != null) {
|
|
cache.sorts[index] = _sort;
|
|
} else {
|
|
cache.sorts.add(_sort);
|
|
}
|
|
await cache.save();
|
|
|
|
//Preload for sorting
|
|
if (playlist!.tracks!.length < playlist!.trackCount!) {
|
|
playlist = await deezerAPI.fullPlaylist(playlist!.id);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
playlist = widget.playlist;
|
|
_sort = Sorting(sourceType: SortSourceTypes.PLAYLIST, id: playlist!.id);
|
|
//If scrolled past 90% load next tracks
|
|
_scrollController.addListener(() {
|
|
double off = _scrollController.position.maxScrollExtent * 0.90;
|
|
if (_scrollController.position.pixels > off) {
|
|
_load();
|
|
}
|
|
});
|
|
//Load if no tracks
|
|
if (playlist!.tracks!.isEmpty) {
|
|
//Get correct metadata
|
|
deezerAPI.playlist(playlist!.id).then((Playlist p) {
|
|
setState(() {
|
|
playlist = p;
|
|
});
|
|
//Load tracks
|
|
_load();
|
|
}).catchError((e) {
|
|
setState(() => _error = true);
|
|
});
|
|
}
|
|
|
|
_restoreSort();
|
|
|
|
super.initState();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: Text(playlist!.title ?? '')),
|
|
body: Scrollbar(
|
|
interactive: true,
|
|
controller: _scrollController,
|
|
thickness: 8.0,
|
|
child: ListView(
|
|
controller: _scrollController,
|
|
children: <Widget>[
|
|
const SizedBox(height: 4.0),
|
|
ConstrainedBox(
|
|
constraints: BoxConstraints.tight(
|
|
Size.fromHeight(MediaQuery.of(context).size.height / 3)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
vertical: 4.0, horizontal: 8.0),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.max,
|
|
children: <Widget>[
|
|
Flexible(
|
|
child: CachedImage(
|
|
url: playlist!.image!.full,
|
|
rounded: true,
|
|
fullThumb: true,
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: min(
|
|
MediaQuery.of(context).size.width / 16, 60.0)),
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Text(
|
|
playlist!.title!,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: TextAlign.start,
|
|
maxLines: 3,
|
|
style: const TextStyle(
|
|
fontSize: 20.0, fontWeight: FontWeight.bold),
|
|
),
|
|
Text(
|
|
playlist!.user!.name ?? '',
|
|
overflow: TextOverflow.ellipsis,
|
|
maxLines: 2,
|
|
textAlign: TextAlign.start,
|
|
style: TextStyle(
|
|
color: Theme.of(context).primaryColor,
|
|
fontSize: 17.0),
|
|
),
|
|
const SizedBox(height: 16.0),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
Icon(
|
|
Icons.audiotrack,
|
|
size: 20.0,
|
|
semanticLabel: "Tracks".i18n,
|
|
),
|
|
const SizedBox(width: 8.0),
|
|
Text(
|
|
(playlist!.trackCount ??
|
|
playlist!.tracks!.length)
|
|
.toString(),
|
|
style: const TextStyle(fontSize: 16),
|
|
)
|
|
],
|
|
),
|
|
const SizedBox(height: 6.0),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
Icon(
|
|
Icons.timelapse,
|
|
size: 32.0,
|
|
semanticLabel: "Duration".i18n,
|
|
),
|
|
const SizedBox(width: 8.0),
|
|
Text(
|
|
playlist!.durationString,
|
|
style: const TextStyle(fontSize: 16),
|
|
)
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (playlist!.description != null &&
|
|
playlist!.description!.isNotEmpty)
|
|
const FreezerDivider(),
|
|
if (playlist!.description != null &&
|
|
playlist!.description!.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.all(6.0),
|
|
child: Text(
|
|
playlist!.description ?? '',
|
|
maxLines: 4,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(fontSize: 16.0),
|
|
),
|
|
),
|
|
const FreezerDivider(),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.max,
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
children: <Widget>[
|
|
MakePlaylistOffline(playlist),
|
|
if (playlist!.user!.name != deezerAPI.userName)
|
|
IconButton(
|
|
icon: Icon(
|
|
playlist!.library!
|
|
? Icons.favorite
|
|
: Icons.favorite_outline,
|
|
size: 32,
|
|
semanticLabel:
|
|
playlist!.library! ? "Unlove".i18n : "Love".i18n,
|
|
),
|
|
onPressed: () async {
|
|
//Add to library
|
|
if (!playlist!.library!) {
|
|
await deezerAPI.addPlaylist(playlist!.id);
|
|
ScaffoldMessenger.of(context)
|
|
.snack('Added to library'.i18n);
|
|
setState(() => playlist!.library = true);
|
|
return;
|
|
}
|
|
//Remove
|
|
await deezerAPI.removePlaylist(playlist!.id);
|
|
ScaffoldMessenger.of(context)
|
|
.snack('Playlist removed from library!'.i18n);
|
|
setState(() => playlist!.library = false);
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: Icon(
|
|
Icons.file_download,
|
|
size: 32.0,
|
|
semanticLabel: "Download".i18n,
|
|
),
|
|
onPressed: () async {
|
|
if (await downloadManager.addOfflinePlaylist(playlist,
|
|
private: false, context: context) !=
|
|
false) MenuSheet(context).showDownloadStartedToast();
|
|
},
|
|
),
|
|
PopupMenuButton(
|
|
color: Theme.of(context).scaffoldBackgroundColor,
|
|
onSelected: (SortType s) async {
|
|
if (playlist!.tracks!.length < playlist!.trackCount!) {
|
|
//Preload whole playlist
|
|
playlist = await deezerAPI.fullPlaylist(playlist!.id);
|
|
}
|
|
setState(() => _sort!.type = s);
|
|
|
|
//Save sort type to cache
|
|
int? index = Sorting.index(SortSourceTypes.PLAYLIST,
|
|
id: playlist!.id);
|
|
if (index == null) {
|
|
cache.sorts.add(_sort);
|
|
} else {
|
|
cache.sorts[index] = _sort;
|
|
}
|
|
await cache.save();
|
|
},
|
|
itemBuilder: (context) => <PopupMenuEntry<SortType>>[
|
|
PopupMenuItem(
|
|
value: SortType.DEFAULT,
|
|
child:
|
|
Text('Default'.i18n, style: popupMenuTextStyle()),
|
|
),
|
|
PopupMenuItem(
|
|
value: SortType.ALPHABETIC,
|
|
child: Text('Alphabetic'.i18n,
|
|
style: popupMenuTextStyle()),
|
|
),
|
|
PopupMenuItem(
|
|
value: SortType.ARTIST,
|
|
child: Text('Artist'.i18n, style: popupMenuTextStyle()),
|
|
),
|
|
PopupMenuItem(
|
|
value: SortType.DATE_ADDED,
|
|
child: Text('Date added'.i18n,
|
|
style: popupMenuTextStyle()),
|
|
),
|
|
],
|
|
child: Icon(
|
|
Icons.sort,
|
|
size: 32.0,
|
|
semanticLabel: "Sort playlist".i18n,
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: Icon(
|
|
_sort!.reverse!
|
|
? FontAwesome5.sort_alpha_up
|
|
: FontAwesome5.sort_alpha_down,
|
|
semanticLabel: _sort!.reverse!
|
|
? "Sort descending".i18n
|
|
: "Sort ascending".i18n,
|
|
),
|
|
onPressed: () => _reverse(),
|
|
),
|
|
Container(width: 4.0)
|
|
],
|
|
),
|
|
const FreezerDivider(),
|
|
if (playlist!.tracks!.isEmpty)
|
|
const Center(child: CircularProgressIndicator()),
|
|
...List.generate(playlist!.tracks!.length, (i) {
|
|
Track t = sorted[i];
|
|
return TrackTile.fromTrack(t, onTap: () {
|
|
Playlist p = Playlist(
|
|
title: playlist!.title, id: playlist!.id, tracks: sorted);
|
|
playerHelper.playFromPlaylist(p, t.id);
|
|
}, onSecondary: (details) {
|
|
MenuSheet m = MenuSheet(context);
|
|
m.defaultTrackMenu(t, details: details, options: [
|
|
if (playlist!.user!.id == deezerAPI.userId)
|
|
m.removeFromPlaylist(t, playlist)
|
|
]);
|
|
});
|
|
}),
|
|
if (_loading)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 8.0),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: <Widget>[CircularProgressIndicator()],
|
|
),
|
|
),
|
|
if (_error) const ErrorScreen()
|
|
],
|
|
),
|
|
));
|
|
}
|
|
}
|
|
|
|
class MakePlaylistOffline extends StatefulWidget {
|
|
final Playlist? playlist;
|
|
const MakePlaylistOffline(this.playlist, {Key? key}) : super(key: key);
|
|
|
|
@override
|
|
State<MakePlaylistOffline> createState() => _MakePlaylistOfflineState();
|
|
}
|
|
|
|
class _MakePlaylistOfflineState extends State<MakePlaylistOffline> {
|
|
bool _offline = false;
|
|
|
|
@override
|
|
void initState() {
|
|
downloadManager.checkOffline(playlist: widget.playlist).then((v) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_offline = v;
|
|
});
|
|
}
|
|
});
|
|
super.initState();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
children: <Widget>[
|
|
Switch(
|
|
value: _offline,
|
|
onChanged: (v) async {
|
|
if (v) {
|
|
//Add to offline
|
|
if (widget.playlist!.user != null &&
|
|
widget.playlist!.user!.id != deezerAPI.userId) {
|
|
await deezerAPI.addPlaylist(widget.playlist!.id);
|
|
}
|
|
downloadManager.addOfflinePlaylist(widget.playlist,
|
|
private: true);
|
|
MenuSheet(context).showDownloadStartedToast();
|
|
setState(() {
|
|
_offline = true;
|
|
});
|
|
return;
|
|
}
|
|
downloadManager.removeOfflinePlaylist(widget.playlist!.id);
|
|
ScaffoldMessenger.of(context)
|
|
.snack("Playlist removed from offline!".i18n);
|
|
setState(() {
|
|
_offline = false;
|
|
});
|
|
},
|
|
),
|
|
const SizedBox(width: 4.0),
|
|
Text(
|
|
'Offline'.i18n,
|
|
style: const TextStyle(fontSize: 16),
|
|
)
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class ShowScreen extends StatefulWidget {
|
|
final Show? show;
|
|
const ShowScreen(this.show, {Key? key}) : super(key: key);
|
|
|
|
@override
|
|
State<ShowScreen> createState() => _ShowScreenState();
|
|
}
|
|
|
|
class _ShowScreenState extends State<ShowScreen> {
|
|
Show? _show;
|
|
bool _loading = true;
|
|
bool _error = false;
|
|
List<ShowEpisode>? _episodes;
|
|
|
|
Future _load() async {
|
|
//Fetch
|
|
List<ShowEpisode>? e;
|
|
try {
|
|
e = await deezerAPI.allShowEpisodes(_show!.id);
|
|
} catch (e) {
|
|
setState(() {
|
|
_loading = false;
|
|
_error = true;
|
|
});
|
|
return;
|
|
}
|
|
setState(() {
|
|
_episodes = e;
|
|
_loading = false;
|
|
});
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
_show = widget.show;
|
|
_load();
|
|
super.initState();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: Text(_show!.name!)),
|
|
body: ListView(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxHeight: MediaQuery.of(context).size.height / 3),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.max,
|
|
children: [
|
|
Flexible(
|
|
child: AspectRatio(
|
|
aspectRatio: 1.0,
|
|
child: CachedImage(
|
|
url: _show!.art!.full,
|
|
rounded: true,
|
|
),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: min(MediaQuery.of(context).size.width / 16, 60.0)),
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(_show!.name!,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 20.0, fontWeight: FontWeight.bold)),
|
|
const SizedBox(height: 16.0),
|
|
Text(
|
|
_show!.description!,
|
|
maxLines: 6,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontSize: 16.0),
|
|
)
|
|
],
|
|
),
|
|
)
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 4.0),
|
|
const FreezerDivider(),
|
|
|
|
//Error
|
|
if (_error) const ErrorScreen(),
|
|
|
|
//Loading
|
|
if (_loading) const Center(child: CircularProgressIndicator()),
|
|
|
|
//Data
|
|
if (!_loading && !_error)
|
|
...List.generate(_episodes!.length, (i) {
|
|
ShowEpisode e = _episodes![i];
|
|
return ShowEpisodeTile(
|
|
e,
|
|
onSecondary: (details) {
|
|
MenuSheet m = MenuSheet(context);
|
|
m.defaultShowEpisodeMenu(_show!, e, details: details);
|
|
},
|
|
onTap: () async {
|
|
await playerHelper.playShowEpisode(_show!, _episodes!,
|
|
index: i);
|
|
},
|
|
);
|
|
})
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|