Updated packages, rewrote player = gapless playback, faster loading

This commit is contained in:
exttex 2020-08-13 19:39:22 +02:00
parent 6f250df004
commit d4299f736f
92 changed files with 10270 additions and 1450 deletions

View file

@ -1,209 +1,66 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'dart:io';
import 'dart:convert';
import 'package:cached_network_image/cached_network_image.dart';
ImagesDatabase imagesDatabase = ImagesDatabase();
class ImagesDatabase {
/*
images.db:
Table: images
Fields:
id - id
name - md5 hash of url. also filename
url - url
permanent - 0/1 - if image is cached or offline
!!! Using the wrappers so i don't have to rewrite most of the code, because of migration to cached network image
*/
Database db;
String imagesPath;
ImageProvider placeholderThumb = new AssetImage('assets/cover_thumb.jpg');
//Prepare database
Future init() async {
String dir = await getDatabasesPath();
String path = p.join(dir, 'images.db');
db = await openDatabase(
path,
version: 1,
singleInstance: false,
onCreate: (Database db, int version) async {
//Create table on db created
await db.execute('CREATE TABLE images (id INTEGER PRIMARY KEY, name TEXT, url TEXT, permanent INTEGER)');
}
);
//Prepare folders
imagesPath = p.join((await getApplicationDocumentsDirectory()).path, 'images/');
Directory imagesDir = Directory(imagesPath);
await imagesDir.create(recursive: true);
void saveImage(String url) {
CachedNetworkImageProvider(url);
}
String getPath(String name) {
return p.join(imagesPath, name);
Future<PaletteGenerator> getPaletteGenerator(String url) {
return PaletteGenerator.fromImageProvider(CachedNetworkImageProvider(url));
}
//Get image url/path, cache it
Future<String> getImage(String url, {bool permanent = false}) async {
//Already file
if (!url.startsWith('http')) {
url = url.replaceFirst('file://', '');
if (!permanent) return url;
//Update in db to permanent
String name = p.basenameWithoutExtension(url);
await db.rawUpdate('UPDATE images SET permanent == 1 WHERE name == ?', [name]);
}
//Filename = md5 hash
String hash = md5.convert(utf8.encode(url)).toString();
List<Map> results = await db.rawQuery('SELECT * FROM images WHERE name == ?', [hash]);
String path = getPath(hash);
if (results.length > 0) {
//Image in database
return path;
}
//Save image
Dio dio = Dio();
try {
await dio.download(url, path);
await db.insert('images', {'url': url, 'name': hash, 'permanent': permanent?1:0});
return path;
} catch (e) {
return null;
}
}
Future<PaletteGenerator> getPaletteGenerator(String url) async {
String path = await getImage(url);
//Get image provider
ImageProvider provider = placeholderThumb;
if (path != null) {
provider = FileImage(File(path));
}
PaletteGenerator paletteGenerator = await PaletteGenerator.fromImageProvider(provider);
return paletteGenerator;
}
//Get primary color from album art
Future<Color> getPrimaryColor(String url) async {
PaletteGenerator paletteGenerator = await getPaletteGenerator(url);
return paletteGenerator.colors.first;
}
//Check if is dark
Future<bool> isDark(String url) async {
PaletteGenerator paletteGenerator = await getPaletteGenerator(url);
return paletteGenerator.colors.first.computeLuminance() > 0.5 ? false : true;
}
}
class CachedImage extends StatefulWidget {
final String url;
final double width;
final double height;
final bool circular;
final bool fullThumb;
const CachedImage({Key key, this.url, this.height, this.width, this.circular = false}): super(key: key);
const CachedImage({Key key, this.url, this.height, this.width, this.circular = false, this.fullThumb = false}): super(key: key);
@override
_CachedImageState createState() => _CachedImageState();
}
class _CachedImageState extends State<CachedImage> {
ImageProvider _image = imagesDatabase.placeholderThumb;
double _opacity = 0.0;
bool _disposed = false;
String _prevUrl;
Future<ImageProvider> _getImage() async {
//Image already path
if (!widget.url.startsWith('http')) {
//Remove file://, if used in audio_service
if (widget.url.startsWith('/')) return FileImage(File(widget.url));
return FileImage(File(widget.url.replaceFirst('file://', '')));
}
//Load image from db
String path = await imagesDatabase.getImage(widget.url);
if (path == null) return imagesDatabase.placeholderThumb;
return FileImage(File(path));
}
//Load image and fade
void _load() async {
if (_prevUrl == widget.url) return;
ImageProvider image = await _getImage();
if (_disposed) return;
setState(() {
_image = image;
_opacity = 1.0;
});
_prevUrl = widget.url;
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
@override
void initState() {
_load();
super.initState();
}
@override
void didUpdateWidget(CachedImage oldWidget) {
_load();
super.didUpdateWidget(oldWidget);
}
@override
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
widget.circular ?
CircleAvatar(
radius: (widget.width??widget.height),
backgroundImage: imagesDatabase.placeholderThumb,
):
Image(
image: imagesDatabase.placeholderThumb,
height: widget.height,
width: widget.width,
),
if (widget.circular) return ClipOval(
child: CachedImage(url: widget.url, height: widget.height, width: widget.width, circular: false)
);
AnimatedOpacity(
duration: Duration(milliseconds: 250),
opacity: _opacity,
child: widget.circular ?
CircleAvatar(
radius: (widget.width??widget.height),
backgroundImage: _image,
):
Image(
image: _image,
height: widget.height,
width: widget.width,
),
)
],
return CachedNetworkImage(
imageUrl: widget.url,
width: widget.width,
height: widget.height,
placeholder: (context, url) {
if (widget.fullThumb) return Image.asset('assets/cover.jpg', width: widget.width, height: widget.height,);
return Image.asset('assets/cover_thumb.jpg', width: widget.width, height: widget.height);
},
errorWidget: (context, url, error) => Image.asset('assets/cover_thumb.jpg', width: widget.width, height: widget.height),
);
}
}

View file

@ -394,7 +394,19 @@ class ArtistDetails extends StatelessWidget {
fontSize: 22.0
),
),
...List.generate(artist.albums.length, (i) {
...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'),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => DiscographyScreen(artist: artist,))
);
}
);
}
//Top albums
Album a = artist.albums[i];
return AlbumTile(
a,
@ -419,6 +431,103 @@ class ArtistDetails extends StatelessWidget {
}
}
class DiscographyScreen extends StatefulWidget {
Artist artist;
DiscographyScreen({@required this.artist, Key key}): super(key: key);
@override
_DiscographyScreenState createState() => _DiscographyScreenState();
}
class _DiscographyScreenState extends State<DiscographyScreen> {
Artist artist;
bool _loading = false;
bool _error = false;
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;
});
}
@override
void initState() {
artist = widget.artist;
//Lazy loading scroll
_scrollController.addListener(() {
double off = _scrollController.position.maxScrollExtent * 0.90;
if (_scrollController.position.pixels > off) {
_load();
}
});
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Discography'),),
body: ListView.builder(
controller: _scrollController,
itemCount: artist.albums.length + 1,
itemBuilder: (context, i) {
//Loading
if (i == artist.albums.length) {
if (_loading)
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [CircularProgressIndicator()],
);
//Error
if (_error)
return ErrorScreen();
//Success
return Container(width: 0, height: 0,);
}
Album a = artist.albums[i];
return AlbumTile(
a,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => AlbumDetails(a))
);
},
onHold: () {
MenuSheet m = MenuSheet(context);
m.defaultAlbumMenu(a);
},
);
},
),
);
}
}
class PlaylistDetails extends StatefulWidget {

View file

@ -140,15 +140,8 @@ class MenuSheet {
title: Text('Play next'),
leading: Icon(Icons.playlist_play),
onTap: () async {
if (playerHelper.queueIndex == -1) {
//First track
await AudioService.addQueueItem(t.toMediaItem());
await AudioService.play();
} else {
//Normal
await AudioService.addQueueItemAt(
t.toMediaItem(), playerHelper.queueIndex + 1);
}
//-1 = next
await AudioService.addQueueItemAt(t.toMediaItem(), -1);
_close();
});

View file

@ -32,7 +32,7 @@ class PlayerBar extends StatelessWidget {
leading: CachedImage(
width: 50,
height: 50,
url: AudioService.currentMediaItem.artUri,
url: AudioService.currentMediaItem.extras['thumb'] ?? AudioService.currentMediaItem.artUri,
),
title: Text(
AudioService.currentMediaItem.displayTitle,

View file

@ -6,10 +6,12 @@ import 'package:audio_service/audio_service.dart';
import 'package:flutter_screenutil/screenutil.dart';
import 'package:freezer/api/deezer.dart';
import 'package:freezer/api/player.dart';
import 'package:freezer/settings.dart';
import 'package:freezer/ui/menu.dart';
import 'package:freezer/ui/settings_screen.dart';
import 'package:freezer/ui/tiles.dart';
import 'package:async/async.dart';
import 'package:just_audio/just_audio.dart';
import 'package:marquee/marquee.dart';
import 'cached_image.dart';
@ -84,9 +86,10 @@ class _PlayerScreenHorizontalState extends State<PlayerScreenHorizontal> {
children: <Widget>[
CachedImage(
url: AudioService.currentMediaItem.artUri,
fullThumb: true,
),
if (_lyrics) LyricsWidget(
artUri: AudioService.currentMediaItem.artUri,
artUri: AudioService.currentMediaItem.extras['thumb'],
trackId: AudioService.currentMediaItem.id,
lyrics: Track.fromMediaItem(AudioService.currentMediaItem).lyrics,
height: ScreenUtil().setWidth(500),
@ -188,7 +191,7 @@ class _PlayerScreenHorizontalState extends State<PlayerScreenHorizontal> {
MaterialPageRoute(builder: (context) => QualitySettings())
),
child: Text(
AudioService.currentMediaItem.extras['qualityString'],
AudioService.currentMediaItem.extras['qualityString'] ?? '',
style: TextStyle(fontSize: ScreenUtil().setSp(24)),
),
),
@ -242,9 +245,10 @@ class _PlayerScreenVerticalState extends State<PlayerScreenVertical> {
children: <Widget>[
CachedImage(
url: AudioService.currentMediaItem.artUri,
fullThumb: true,
),
if (_lyrics) LyricsWidget(
artUri: AudioService.currentMediaItem.artUri,
artUri: AudioService.currentMediaItem.extras['thumb'],
trackId: AudioService.currentMediaItem.id,
lyrics: Track.fromMediaItem(AudioService.currentMediaItem).lyrics,
height: ScreenUtil().setHeight(1050),
@ -322,7 +326,7 @@ class _PlayerScreenVerticalState extends State<PlayerScreenVertical> {
MaterialPageRoute(builder: (context) => QualitySettings())
),
child: Text(
AudioService.currentMediaItem.extras['qualityString'],
AudioService.currentMediaItem.extras['qualityString'] ?? '',
style: TextStyle(
fontSize: ScreenUtil().setSp(32),
),
@ -574,15 +578,15 @@ class _RepeatButtonState extends State<RepeatButton> {
Icon get icon {
switch (playerHelper.repeatType) {
case RepeatType.NONE:
case LoopMode.off:
return Icon(Icons.repeat, size: widget.size??_size);
case RepeatType.LIST:
case LoopMode.all:
return Icon(
Icons.repeat,
color: Theme.of(context).primaryColor,
size: widget.size??_size
);
case RepeatType.TRACK:
case LoopMode.one:
return Icon(
Icons.repeat_one,
color: Theme.of(context).primaryColor,
@ -708,6 +712,18 @@ class QueueScreen extends StatefulWidget {
}
class _QueueScreenState extends State<QueueScreen> {
//Get proper icon color by theme
Color get shuffleIconColor {
Color og = Theme.of(context).primaryColor;
if (og.computeLuminance() > 0.5) {
if (playerHelper.shuffle) return Theme.of(context).primaryColorLight;
return Colors.black;
}
if (playerHelper.shuffle) return Theme.of(context).primaryColorDark;
return Colors.white;
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -715,10 +731,13 @@ class _QueueScreenState extends State<QueueScreen> {
title: Text('Queue'),
actions: <Widget>[
IconButton(
icon: Icon(Icons.shuffle),
icon: Icon(
Icons.shuffle,
color: shuffleIconColor
),
onPressed: () async {
await AudioService.customAction('shuffleQueue');
setState(() => {});
await playerHelper.toggleShuffle();
setState(() {});
},
)
],

View file

@ -1,3 +1,4 @@
import 'package:connectivity/connectivity.dart';
import 'package:flutter/material.dart';
import 'package:freezer/api/download.dart';
import 'package:freezer/api/player.dart';
@ -7,7 +8,6 @@ import 'package:freezer/ui/menu.dart';
import 'tiles.dart';
import '../api/deezer.dart';
import '../api/definitions.dart';
import '../settings.dart';
import 'error.dart';
class SearchScreen extends StatefulWidget {
@ -18,7 +18,7 @@ class SearchScreen extends StatefulWidget {
class _SearchScreenState extends State<SearchScreen> {
String _query;
bool _offline = settings.offlineMode;
bool _offline = false;
void _submit(BuildContext context, {String query}) {
if (query != null) _query = query;
@ -27,6 +27,19 @@ class _SearchScreenState extends State<SearchScreen> {
);
}
@override
void initState() {
//Check for connectivity and enable offline mode
Connectivity().checkConnectivity().then((res) {
if (res == ConnectivityResult.none) setState(() {
_offline = true;
});
});
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -59,11 +72,7 @@ class _SearchScreenState extends State<SearchScreen> {
leading: Switch(
value: _offline,
onChanged: (v) {
if (settings.offlineMode) {
setState(() => _offline = true);
} else {
setState(() => _offline = v);
}
setState(() => _offline = !_offline);
},
),
)