Updated packages, rewrote player = gapless playback, faster loading
This commit is contained in:
parent
6f250df004
commit
d4299f736f
92 changed files with 10270 additions and 1450 deletions
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(() {});
|
||||
},
|
||||
)
|
||||
],
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
),
|
||||
)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue