check if user can stream hq or flac
use get_url api by default, and fall back to old generation if get_url failed start to write a better cachemanager to implement in all systems write in more appropriate directories on windows and linux improve check for Connectivity by adding a fallback (needed for example on linux systems without NetworkManager) allow to dynamically change track quality without rebuilding the object
This commit is contained in:
parent
b629998416
commit
f126ffef46
|
@ -51,7 +51,7 @@ android/app/.cxx
|
|||
.pub/
|
||||
/build/
|
||||
.gradle/
|
||||
|
||||
*.g.dart
|
||||
# Web related
|
||||
lib/generated_plugin_registrant.dart
|
||||
|
||||
|
|
|
@ -57,6 +57,11 @@ class Cache {
|
|||
@HiveField(7)
|
||||
String? favoritesPlaylistId;
|
||||
|
||||
@HiveField(8, defaultValue: false)
|
||||
bool canStreamHQ = false;
|
||||
@HiveField(9, defaultValue: false)
|
||||
bool canStreamLossless = false;
|
||||
|
||||
@JsonKey(includeToJson: false, includeFromJson: false)
|
||||
bool wakelock = false;
|
||||
|
||||
|
|
|
@ -1,198 +0,0 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'cache.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class CacheAdapter extends TypeAdapter<Cache> {
|
||||
@override
|
||||
final int typeId = 22;
|
||||
|
||||
@override
|
||||
Cache read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return Cache()
|
||||
..libraryTracks =
|
||||
fields[0] == null ? [] : (fields[0] as List).cast<String>()
|
||||
..loggedTrackId = fields[1] as String?
|
||||
..history = (fields[2] as List).cast<Track>()
|
||||
..sorts = (fields[3] as List).cast<Sorting?>()
|
||||
..searchHistory = (fields[4] as List).cast<SearchHistoryItem>()
|
||||
..threadsWarning = fields[5] as bool
|
||||
..lastUpdateCheck = fields[6] as DateTime?
|
||||
..favoritesPlaylistId = fields[7] as String?;
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, Cache obj) {
|
||||
writer
|
||||
..writeByte(8)
|
||||
..writeByte(0)
|
||||
..write(obj.libraryTracks)
|
||||
..writeByte(1)
|
||||
..write(obj.loggedTrackId)
|
||||
..writeByte(2)
|
||||
..write(obj.history)
|
||||
..writeByte(3)
|
||||
..write(obj.sorts)
|
||||
..writeByte(4)
|
||||
..write(obj.searchHistory)
|
||||
..writeByte(5)
|
||||
..write(obj.threadsWarning)
|
||||
..writeByte(6)
|
||||
..write(obj.lastUpdateCheck)
|
||||
..writeByte(7)
|
||||
..write(obj.favoritesPlaylistId);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is CacheAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
|
||||
class SearchHistoryItemAdapter extends TypeAdapter<SearchHistoryItem> {
|
||||
@override
|
||||
final int typeId = 20;
|
||||
|
||||
@override
|
||||
SearchHistoryItem read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return SearchHistoryItem(
|
||||
fields[1] as dynamic,
|
||||
fields[0] as SearchHistoryItemType,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, SearchHistoryItem obj) {
|
||||
writer
|
||||
..writeByte(2)
|
||||
..writeByte(0)
|
||||
..write(obj.type)
|
||||
..writeByte(1)
|
||||
..write(obj.data);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SearchHistoryItemAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
|
||||
class SearchHistoryItemTypeAdapter extends TypeAdapter<SearchHistoryItemType> {
|
||||
@override
|
||||
final int typeId = 21;
|
||||
|
||||
@override
|
||||
SearchHistoryItemType read(BinaryReader reader) {
|
||||
switch (reader.readByte()) {
|
||||
case 0:
|
||||
return SearchHistoryItemType.track;
|
||||
case 1:
|
||||
return SearchHistoryItemType.album;
|
||||
case 2:
|
||||
return SearchHistoryItemType.artist;
|
||||
case 3:
|
||||
return SearchHistoryItemType.playlist;
|
||||
default:
|
||||
return SearchHistoryItemType.track;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, SearchHistoryItemType obj) {
|
||||
switch (obj) {
|
||||
case SearchHistoryItemType.track:
|
||||
writer.writeByte(0);
|
||||
break;
|
||||
case SearchHistoryItemType.album:
|
||||
writer.writeByte(1);
|
||||
break;
|
||||
case SearchHistoryItemType.artist:
|
||||
writer.writeByte(2);
|
||||
break;
|
||||
case SearchHistoryItemType.playlist:
|
||||
writer.writeByte(3);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SearchHistoryItemTypeAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
Cache _$CacheFromJson(Map<String, dynamic> json) => Cache()
|
||||
..libraryTracks = (json['libraryTracks'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
[]
|
||||
..history = (json['history'] as List<dynamic>?)
|
||||
?.map((e) => Track.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[]
|
||||
..sorts = (json['sorts'] as List<dynamic>?)
|
||||
?.map((e) =>
|
||||
e == null ? null : Sorting.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[]
|
||||
..searchHistory = (json['searchHistory2'] as List<dynamic>?)
|
||||
?.map((e) => SearchHistoryItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[]
|
||||
..threadsWarning = json['threadsWarning'] as bool? ?? false
|
||||
..lastUpdateCheck = json['lastUpdateCheck'] == null
|
||||
? null
|
||||
: DateTime.parse(json['lastUpdateCheck'] as String)
|
||||
..favoritesPlaylistId = json['favoritesPlaylistId'] as String?;
|
||||
|
||||
Map<String, dynamic> _$CacheToJson(Cache instance) => <String, dynamic>{
|
||||
'libraryTracks': instance.libraryTracks,
|
||||
'history': instance.history.map((e) => e.toJson()).toList(),
|
||||
'sorts': instance.sorts.map((e) => e?.toJson()).toList(),
|
||||
'searchHistory2': instance.searchHistory.map((e) => e.toJson()).toList(),
|
||||
'threadsWarning': instance.threadsWarning,
|
||||
'lastUpdateCheck': instance.lastUpdateCheck?.toIso8601String(),
|
||||
'favoritesPlaylistId': instance.favoritesPlaylistId,
|
||||
};
|
||||
|
||||
SearchHistoryItem _$SearchHistoryItemFromJson(Map<String, dynamic> json) =>
|
||||
SearchHistoryItem(
|
||||
json['data'],
|
||||
SearchHistoryItem._searchHistoryItemTypeFromJson(json['type'] as int),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SearchHistoryItemToJson(SearchHistoryItem instance) =>
|
||||
<String, dynamic>{
|
||||
'type': SearchHistoryItem._searchHistoryItemTypeToJson(instance.type),
|
||||
'data': instance.data,
|
||||
};
|
|
@ -0,0 +1,104 @@
|
|||
// ignore_for_file: implementation_imports
|
||||
|
||||
import 'package:dart_blowfish/dart_blowfish.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_cache_manager/src/storage/cache_object.dart';
|
||||
import 'package:freezer/type_adapters/uri.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
class FreezerCacheManager extends CacheManager {
|
||||
static const key = 'freezerImageCache';
|
||||
|
||||
void init(String path, {String? boxName}) {
|
||||
_instance =
|
||||
FreezerCacheManager._(FreezerCacheInfoRepository(boxName ?? key, path));
|
||||
}
|
||||
|
||||
static late final FreezerCacheManager _instance;
|
||||
factory FreezerCacheManager() => _instance;
|
||||
|
||||
FreezerCacheManager._(FreezerCacheInfoRepository repo)
|
||||
: super(Config(key, repo: repo));
|
||||
}
|
||||
|
||||
class FreezerCacheInfoRepository extends CacheInfoRepository {
|
||||
final String boxName;
|
||||
final String path;
|
||||
late final LazyBox<CacheObject> _box;
|
||||
bool _isOpen;
|
||||
FreezerCacheInfoRepository(this.boxName, this.path);
|
||||
|
||||
@override
|
||||
Future<bool> exists() => Hive.boxExists(boxName, path: path);
|
||||
|
||||
@override
|
||||
Future<bool> open() async {
|
||||
if (_isOpen) return true;
|
||||
|
||||
_box = await Hive.openLazyBox<CacheObject>(boxName, path: path);
|
||||
_isOpen = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic> updateOrInsert(CacheObject cacheObject) {
|
||||
if (cacheObject.id == null) {
|
||||
return insert(cacheObject);
|
||||
} else {
|
||||
return update(cacheObject);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CacheObject> insert(CacheObject cacheObject,
|
||||
{bool setTouchedToNow = true}) {
|
||||
final id = await _box.add(cacheObject);
|
||||
}
|
||||
|
||||
/// Gets a [CacheObject] by [key]
|
||||
@override
|
||||
Future<CacheObject?> get(String key);
|
||||
|
||||
/// Deletes a cache object by [id]
|
||||
@override
|
||||
Future<int> delete(int id);
|
||||
|
||||
/// Deletes items with [ids] from the repository
|
||||
@override
|
||||
Future<int> deleteAll(Iterable<int> ids);
|
||||
|
||||
/// Updates an existing [cacheObject]
|
||||
@override
|
||||
Future<int> update(CacheObject cacheObject, {bool setTouchedToNow = true});
|
||||
|
||||
/// Gets the list of all objects in the cache
|
||||
@override
|
||||
Future<List<CacheObject>> getAllObjects();
|
||||
|
||||
/// Gets the list of [CacheObject] that can be removed if the repository is over capacity.
|
||||
///
|
||||
/// The exact implementation is up to the repository, but implementations should
|
||||
/// return a preferred list of items. For example, the least recently accessed
|
||||
@override
|
||||
Future<List<CacheObject>> getObjectsOverCapacity(int capacity);
|
||||
|
||||
/// Returns a list of [CacheObject] that are older than [maxAge]
|
||||
@override
|
||||
Future<List<CacheObject>> getOldObjects(Duration maxAge);
|
||||
|
||||
/// Close the connection to the repository. If this is the last connection
|
||||
/// to the repository it will return true and the repository is truly
|
||||
/// closed. If there are still open connections it will return false;
|
||||
@override
|
||||
Future<bool> close();
|
||||
|
||||
/// Deletes the cache data file including all cache data.
|
||||
@override
|
||||
Future<void> deleteDataFile();
|
||||
}
|
||||
|
||||
class CacheObjectAdapter extends TypeAdapter<CacheObject> {
|
||||
@override
|
||||
// TODO: implement typeId
|
||||
int get typeId => throw UnimplementedError();
|
||||
}
|
|
@ -26,6 +26,9 @@ class DeezerAPI {
|
|||
String? userName;
|
||||
String? favoritesPlaylistId;
|
||||
String? sid;
|
||||
late String licenseToken;
|
||||
late bool canStreamLossless;
|
||||
late bool canStreamHQ;
|
||||
|
||||
Future<bool>? _authorizing;
|
||||
|
||||
|
@ -125,10 +128,21 @@ class DeezerAPI {
|
|||
userId = data['results']['USER']['USER_ID'].toString();
|
||||
userName = data['results']['USER']['BLOG_NAME'];
|
||||
favoritesPlaylistId = data['results']['USER']['LOVEDTRACKS_ID'];
|
||||
canStreamHQ = data['results']['USER']['OPTIONS']['web_hq'] ||
|
||||
data['results']['USER']['OPTIONS']['mobile_hq'];
|
||||
canStreamLossless = data['results']['USER']['OPTIONS']
|
||||
['web_lossless'] ||
|
||||
data['results']['USER']['OPTIONS']['mobile_lossless'];
|
||||
licenseToken =
|
||||
data['results']['USER']['OPTIONS']['license_token'] as String;
|
||||
|
||||
settings.checkQuality(canStreamHQ, canStreamLossless);
|
||||
cache.canStreamHQ = canStreamHQ;
|
||||
cache.canStreamLossless = canStreamLossless;
|
||||
if (cache.favoritesPlaylistId != favoritesPlaylistId) {
|
||||
cache.favoritesPlaylistId = favoritesPlaylistId;
|
||||
await cache.save();
|
||||
}
|
||||
await cache.save();
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
|
@ -191,6 +205,48 @@ class DeezerAPI {
|
|||
}
|
||||
}
|
||||
|
||||
Future<GetTrackUrlResponse> getTrackUrl(
|
||||
String trackToken, String format) async =>
|
||||
(await getTracksUrl([trackToken], format))[0];
|
||||
|
||||
Future<List<GetTrackUrlResponse>> getTracksUrl(
|
||||
List<String> trackTokens, String format) async {
|
||||
final response = await http.post(
|
||||
Uri.https('media.deezer.com', '/v1/get_url'),
|
||||
body: jsonEncode({
|
||||
"license_token": licenseToken,
|
||||
"media": [
|
||||
{
|
||||
"type": "FULL",
|
||||
"formats": [
|
||||
{"cipher": "BF_CBC_STRIPE", "format": format}
|
||||
],
|
||||
}
|
||||
],
|
||||
"track_tokens": trackTokens,
|
||||
}),
|
||||
headers: headers,
|
||||
);
|
||||
final data = (jsonDecode(response.body) as Map)['data'] as List;
|
||||
return data.map((data) {
|
||||
if (data['errors'] != null) {
|
||||
if (data['errors'][0]['code'] == 2002) {
|
||||
return GetTrackUrlResponse(error: 'Wrong geolocation');
|
||||
}
|
||||
|
||||
return GetTrackUrlResponse(
|
||||
error: (data['errors'][0] as Map).toString());
|
||||
}
|
||||
|
||||
if (data['media'] == null) return GetTrackUrlResponse();
|
||||
return GetTrackUrlResponse(
|
||||
sources: (data['media'][0]['sources'] as List)
|
||||
.map<TrackUrlSource>((e) => TrackUrlSource.fromPrivateJson(
|
||||
(e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false));
|
||||
}).toList(growable: false);
|
||||
}
|
||||
|
||||
//Search
|
||||
Future<SearchResults> search(String? query) async {
|
||||
Map<dynamic, dynamic> data = await callApi('deezer.pageSearch',
|
||||
|
@ -360,9 +416,6 @@ class DeezerAPI {
|
|||
Map data = await callApi('deezer.pageSmartTracklist',
|
||||
params: {'smarttracklist_id': id});
|
||||
|
||||
getExternalStorageDirectory().then((value) =>
|
||||
File(join(value!.path, 'test.json')).writeAsString(jsonEncode(data)));
|
||||
|
||||
return SmartTrackList.fromPrivateJson(data['results'],
|
||||
songsJson: data['results']['SONGS']);
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import 'package:crypto/crypto.dart';
|
|||
import 'package:http/http.dart' as http;
|
||||
import 'package:dart_blowfish/dart_blowfish.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:scrobblenaut/lastfm.dart';
|
||||
|
||||
typedef _IsolateMessage = (
|
||||
Stream<List<int>> source,
|
||||
|
@ -32,6 +33,8 @@ class DeezerAudioSource extends StreamAudioSource {
|
|||
late String _trackId;
|
||||
late String _md5origin;
|
||||
late String _mediaVersion;
|
||||
final String trackToken;
|
||||
final int trackTokenExpiration;
|
||||
final StreamInfoCallback? onStreamObtained;
|
||||
|
||||
// some cache
|
||||
|
@ -45,6 +48,8 @@ class DeezerAudioSource extends StreamAudioSource {
|
|||
required String trackId,
|
||||
required String md5origin,
|
||||
required String mediaVersion,
|
||||
required this.trackToken,
|
||||
required this.trackTokenExpiration,
|
||||
this.onStreamObtained,
|
||||
}) {
|
||||
_getQuality = getQuality;
|
||||
|
@ -119,6 +124,8 @@ class DeezerAudioSource extends StreamAudioSource {
|
|||
}
|
||||
|
||||
Future<Uri> _qualityFallback() async {
|
||||
// only use url generation with MP3_128
|
||||
_currentQuality = AudioQuality.MP3_128;
|
||||
final genUri = _generateTrackUri();
|
||||
|
||||
final req = await http.head(genUri, headers: {
|
||||
|
@ -267,6 +274,32 @@ class DeezerAudioSource extends StreamAudioSource {
|
|||
}
|
||||
}
|
||||
|
||||
Future<Uri?> _getUrl() async {
|
||||
final String actualTrackToken;
|
||||
if (DateTime.now().millisecondsSinceEpoch ~/ 1000 > trackTokenExpiration) {
|
||||
if (!deezerAPI.canStreamHQ && !deezerAPI.canStreamLossless) {
|
||||
_logger.fine('using old url generation, token expired.');
|
||||
}
|
||||
final newTrack = await deezerAPI.track(trackId);
|
||||
actualTrackToken = newTrack.trackToken!;
|
||||
} else {
|
||||
actualTrackToken = trackToken;
|
||||
}
|
||||
|
||||
final res = await deezerAPI.getTrackUrl(
|
||||
actualTrackToken, _currentQuality!.toDeezerQualityString());
|
||||
if (res.error != null) {
|
||||
_logger.warning('Error while getting track url: ${res.error!}');
|
||||
return null;
|
||||
}
|
||||
if (res.sources == null) {
|
||||
_logger.warning('Error while getting track url: No sources!');
|
||||
return null;
|
||||
}
|
||||
|
||||
return Uri.parse(res.sources![0].url);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StreamAudioResponse> request([int? start, int? end]) async {
|
||||
start ??= 0;
|
||||
|
@ -289,21 +322,34 @@ class DeezerAudioSource extends StreamAudioSource {
|
|||
}
|
||||
|
||||
// determine quality to use
|
||||
_currentQuality = _getQuality.call();
|
||||
final newQuality = _getQuality.call();
|
||||
|
||||
final Uri uri;
|
||||
if (_downloadUrl != null) {
|
||||
uri = _downloadUrl!;
|
||||
} else {
|
||||
try {
|
||||
_downloadUrl = uri = await _fallbackUrl();
|
||||
} on QualityException {
|
||||
rethrow;
|
||||
if (_downloadUrl != null && _currentQuality != newQuality) {
|
||||
// update currentUrl to get tracks with new quality
|
||||
_downloadUrl = null;
|
||||
}
|
||||
|
||||
_currentQuality = newQuality;
|
||||
|
||||
if (_downloadUrl == null) {
|
||||
final gottenUrl = await _getUrl();
|
||||
if (gottenUrl != null) {
|
||||
_downloadUrl = gottenUrl;
|
||||
} else {
|
||||
_logger.warning('falling back to old url generation!');
|
||||
|
||||
// OLD URL GENERATION
|
||||
try {
|
||||
_downloadUrl = await _fallbackUrl();
|
||||
} on QualityException {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
_logger.fine("Downloading track from ${uri.toString()}");
|
||||
|
||||
_logger.fine("Downloading track from ${_downloadUrl!.toString()}");
|
||||
final int deezerStart = start - (start % 2048);
|
||||
final req = http.Request('GET', uri)
|
||||
final req = http.Request('GET', _downloadUrl!)
|
||||
..headers.addAll({
|
||||
'User-Agent': deezerAPI.headers['User-Agent']!,
|
||||
'Accept-Language': '*',
|
||||
|
|
|
@ -5,6 +5,7 @@ import 'package:flutter/cupertino.dart';
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:freezer/api/cache.dart';
|
||||
import 'package:freezer/page_routes/blur_slide.dart';
|
||||
import 'package:freezer/page_routes/fade.dart';
|
||||
|
@ -57,6 +58,10 @@ class Track extends DeezerMediaItem {
|
|||
//Date added to playlist / favorites
|
||||
int? addedDate;
|
||||
|
||||
// information for playback
|
||||
String? trackToken;
|
||||
int? trackTokenExpiration;
|
||||
|
||||
@HiveField(13)
|
||||
List<dynamic>? playbackDetails;
|
||||
|
||||
|
@ -75,11 +80,13 @@ class Track extends DeezerMediaItem {
|
|||
this.diskNumber,
|
||||
this.explicit,
|
||||
this.addedDate,
|
||||
this.trackToken,
|
||||
this.trackTokenExpiration,
|
||||
});
|
||||
|
||||
String get artistString => artists == null
|
||||
? ""
|
||||
: artists!.map<String?>((art) => art.name).join(', ');
|
||||
: artists!.map<String>((art) => art.name!).join(', ');
|
||||
String get durationString => durationAsString(duration!);
|
||||
|
||||
static String durationAsString(Duration duration) {
|
||||
|
@ -88,6 +95,9 @@ class Track extends DeezerMediaItem {
|
|||
|
||||
//MediaItem
|
||||
Future<MediaItem> toMediaItem() async {
|
||||
DefaultCacheManager()
|
||||
.getFileFromCache(albumArt!.full)
|
||||
.then((i) => print('file: ${i?.file.uri}'));
|
||||
return MediaItem(
|
||||
title: title!,
|
||||
album: album!.title!,
|
||||
|
@ -104,6 +114,8 @@ class Track extends DeezerMediaItem {
|
|||
"thumb": albumArt!.thumb,
|
||||
"lyrics": jsonEncode(lyrics!.toJson()),
|
||||
"albumId": album!.id,
|
||||
"trackToken": trackToken,
|
||||
"trackTokenExpiration": trackTokenExpiration,
|
||||
"artists":
|
||||
jsonEncode(artists!.map<Map>((art) => art.toJson()).toList())
|
||||
});
|
||||
|
@ -151,21 +163,24 @@ class Track extends DeezerMediaItem {
|
|||
title = "${json['SNG_TITLE']} ${json['VERSION']}";
|
||||
}
|
||||
return Track(
|
||||
id: json['SNG_ID'].toString(),
|
||||
title: title!,
|
||||
duration: Duration(seconds: int.parse(json['DURATION'])),
|
||||
albumArt: DeezerImageDetails(json['ALB_PICTURE']),
|
||||
album: Album.fromPrivateJson(json),
|
||||
artists: (json['ARTISTS'] ?? [json])
|
||||
.map<Artist>((dynamic art) => Artist.fromPrivateJson(art))
|
||||
.toList(),
|
||||
trackNumber: int.parse((json['TRACK_NUMBER'] ?? '0').toString()),
|
||||
playbackDetails: [json['MD5_ORIGIN'], json['MEDIA_VERSION']],
|
||||
lyrics: Lyrics(id: json['LYRICS_ID'].toString()),
|
||||
favorite: favorite,
|
||||
diskNumber: int.parse(json['DISK_NUMBER'] ?? '1'),
|
||||
explicit: (json['EXPLICIT_LYRICS'].toString() == '1') ? true : false,
|
||||
addedDate: json['DATE_ADD']);
|
||||
id: json['SNG_ID'].toString(),
|
||||
title: title!,
|
||||
duration: Duration(seconds: int.parse(json['DURATION'])),
|
||||
albumArt: DeezerImageDetails(json['ALB_PICTURE']),
|
||||
album: Album.fromPrivateJson(json),
|
||||
artists: (json['ARTISTS'] ?? [json])
|
||||
.map<Artist>((dynamic art) => Artist.fromPrivateJson(art))
|
||||
.toList(),
|
||||
trackNumber: int.parse((json['TRACK_NUMBER'] ?? '0').toString()),
|
||||
playbackDetails: [json['MD5_ORIGIN'], json['MEDIA_VERSION']],
|
||||
lyrics: Lyrics(id: json['LYRICS_ID'].toString()),
|
||||
favorite: favorite,
|
||||
diskNumber: int.parse(json['DISK_NUMBER'] ?? '1'),
|
||||
explicit: (json['EXPLICIT_LYRICS'].toString() == '1') ? true : false,
|
||||
addedDate: json['DATE_ADD'],
|
||||
trackToken: json['TRACK_TOKEN'],
|
||||
trackTokenExpiration: json['TRACK_TOKEN_EXPIRE'],
|
||||
);
|
||||
}
|
||||
Map<String, dynamic> toSQL({off = false}) => {
|
||||
'id': id,
|
||||
|
@ -413,7 +428,7 @@ class Artist extends DeezerMediaItem {
|
|||
picture: json.containsKey('ART_PICTURE')
|
||||
? DeezerImageDetails(json['ART_PICTURE'], type: 'artist')
|
||||
: null,
|
||||
albumCount: albumsJson['total'],
|
||||
albumCount: albumsJson['total'] as int?,
|
||||
albums: (albumsJson['data'] ?? [])
|
||||
.map<Album>((dynamic data) => Album.fromPrivateJson(data))
|
||||
.toList(),
|
||||
|
@ -1540,3 +1555,18 @@ extension GetId on FlowConfig {
|
|||
}[this]!;
|
||||
}
|
||||
}
|
||||
|
||||
class TrackUrlSource {
|
||||
final String provider;
|
||||
final String url;
|
||||
TrackUrlSource({required this.provider, required this.url});
|
||||
|
||||
factory TrackUrlSource.fromPrivateJson(Map<String, dynamic> json) =>
|
||||
TrackUrlSource(provider: json['provider'], url: json['url']);
|
||||
}
|
||||
|
||||
class GetTrackUrlResponse {
|
||||
final List<TrackUrlSource>? sources;
|
||||
final String? error;
|
||||
GetTrackUrlResponse({this.sources, this.error});
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,49 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
class Paths {
|
||||
static Future<String> dataDirectory() async {
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.linux:
|
||||
final home = Platform.environment['HOME'];
|
||||
if (home == null) return path.dirname(Platform.resolvedExecutable);
|
||||
if (await Directory(path.join(home, '.local', 'share')).exists()) {
|
||||
final target =
|
||||
await Directory(path.join(home, '.local', 'share', 'freezer'))
|
||||
.create();
|
||||
return target.path;
|
||||
}
|
||||
return path.dirname(Platform.resolvedExecutable);
|
||||
case TargetPlatform.windows:
|
||||
String? home = Platform.environment['USERPROFILE'];
|
||||
if (home == null) {
|
||||
final drive = Platform.environment['HOMEDRIVE'];
|
||||
final homepath = Platform.environment['HOMEPATH'];
|
||||
if (drive == null || homepath == null) {
|
||||
return path.dirname(Platform.resolvedExecutable);
|
||||
}
|
||||
|
||||
home = drive + homepath;
|
||||
}
|
||||
|
||||
final target =
|
||||
await Directory(path.join(home, 'AppData', 'Freezer')).create();
|
||||
return target.path;
|
||||
default:
|
||||
return (await getApplicationDocumentsDirectory()).path;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String> cacheDir() async {
|
||||
if (Platform.isLinux || Platform.isWindows) {
|
||||
final dataDir = await dataDirectory();
|
||||
final target = await Directory(path.join(dataDir, 'cache')).create();
|
||||
return target.path;
|
||||
}
|
||||
|
||||
return (await getTemporaryDirectory()).path;
|
||||
}
|
||||
}
|
|
@ -7,6 +7,8 @@ import 'package:freezer/api/cache.dart';
|
|||
import 'package:freezer/api/deezer.dart';
|
||||
import 'package:freezer/api/deezer_audio_source.dart';
|
||||
import 'package:freezer/api/offline_audio_source.dart';
|
||||
import 'package:freezer/api/paths.dart';
|
||||
import 'package:freezer/api/player/player_helper.dart';
|
||||
import 'package:freezer/api/url_audio_source.dart';
|
||||
import 'package:freezer/ui/android_auto.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
@ -20,9 +22,10 @@ import 'package:freezer/translations.i18n.dart';
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:scrobblenaut/scrobblenaut.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
import 'package:media_kit/media_kit.dart' show MPVLogLevel;
|
||||
|
||||
import 'definitions.dart';
|
||||
import '../settings.dart';
|
||||
import '../definitions.dart';
|
||||
import '../../settings.dart';
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
|
@ -31,291 +34,6 @@ import 'dart:convert';
|
|||
PlayerHelper playerHelper = PlayerHelper();
|
||||
late AudioHandler audioHandler;
|
||||
|
||||
class PlayerHelper {
|
||||
late StreamSubscription _customEventSubscription;
|
||||
late StreamSubscription _mediaItemSubscription;
|
||||
late StreamSubscription _playbackStateStreamSubscription;
|
||||
QueueSource? queueSource;
|
||||
AudioServiceRepeatMode repeatType = AudioServiceRepeatMode.none;
|
||||
int? audioSession;
|
||||
int? _prevAudioSession;
|
||||
bool equalizerOpen = false;
|
||||
bool _shuffleEnabled = false;
|
||||
int _queueIndex = 0;
|
||||
bool _started = false;
|
||||
|
||||
//Visualizer
|
||||
// StreamController _visualizerController = StreamController.broadcast();
|
||||
// Stream get visualizerStream => _visualizerController.stream;
|
||||
|
||||
final _streamInfoSubject = BehaviorSubject<StreamQualityInfo>();
|
||||
ValueStream<StreamQualityInfo> get streamInfo => _streamInfoSubject.stream;
|
||||
|
||||
final _bufferPositionSubject = BehaviorSubject<Duration>();
|
||||
ValueStream<Duration> get bufferPosition => _bufferPositionSubject.stream;
|
||||
|
||||
/// Find queue index by id
|
||||
///
|
||||
/// The function gets more expensive the longer the queue is and the further the element is from the beginning.
|
||||
int getQueueIndexFromId() => audioHandler.mediaItem.value == null
|
||||
? -1
|
||||
: audioHandler.queue.value
|
||||
.indexWhere((mi) => mi.id == audioHandler.mediaItem.value!.id);
|
||||
|
||||
int getQueueIndex() =>
|
||||
audioHandler.playbackState.value.queueIndex ?? getQueueIndexFromId();
|
||||
|
||||
int get queueIndex => _queueIndex;
|
||||
|
||||
Future<void> initAudioHandler() async {
|
||||
final initArgs = AudioPlayerTaskInitArguments.from(
|
||||
settings: settings, deezerAPI: deezerAPI);
|
||||
// initialize our audiohandler instance
|
||||
audioHandler = await AudioService.init(
|
||||
builder: () => AudioPlayerTask(initArgs),
|
||||
config: AudioServiceConfig(
|
||||
notificationColor: settings.primaryColor,
|
||||
androidStopForegroundOnPause: false,
|
||||
androidNotificationOngoing: false,
|
||||
androidNotificationClickStartsActivity: true,
|
||||
androidNotificationChannelDescription: 'Freezer',
|
||||
androidNotificationChannelName: 'Freezer',
|
||||
androidNotificationIcon: 'drawable/ic_logo',
|
||||
preloadArtwork: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> start() async {
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
//Subscribe to custom events
|
||||
_customEventSubscription = audioHandler.customEvent.listen((event) async {
|
||||
if (event is! Map) return;
|
||||
Logger('PlayerHelper').fine("event received: ${event['action']}");
|
||||
switch (event['action']) {
|
||||
case 'onLoad':
|
||||
//After audio_service is loaded, load queue, set quality
|
||||
await settings.updateAudioServiceQuality();
|
||||
break;
|
||||
case 'onRestore':
|
||||
//Load queueSource from isolate
|
||||
queueSource = event['queueSource'] as QueueSource;
|
||||
repeatType = event['repeatMode'] as AudioServiceRepeatMode;
|
||||
_queueIndex = getQueueIndex();
|
||||
break;
|
||||
case 'audioSession':
|
||||
if (!settings.enableEqualizer) break;
|
||||
//Save
|
||||
_prevAudioSession = audioSession;
|
||||
audioSession = event['id'];
|
||||
if (audioSession == null) break;
|
||||
//Open EQ
|
||||
if (!equalizerOpen) {
|
||||
Equalizer.open(event['id']);
|
||||
equalizerOpen = true;
|
||||
break;
|
||||
}
|
||||
//Change session id
|
||||
if (_prevAudioSession != audioSession) {
|
||||
if (_prevAudioSession != null) {
|
||||
Equalizer.removeAudioSessionId(_prevAudioSession!);
|
||||
}
|
||||
Equalizer.setAudioSessionId(audioSession!);
|
||||
}
|
||||
break;
|
||||
//Visualizer data
|
||||
// case 'visualizer':
|
||||
// _visualizerController.add(event['data']);
|
||||
// break;
|
||||
case 'streamInfo':
|
||||
Logger('PlayerHelper').fine("streamInfo received");
|
||||
_streamInfoSubject.add(event['data'] as StreamQualityInfo);
|
||||
break;
|
||||
case 'bufferPosition':
|
||||
_bufferPositionSubject.add(event['data'] as Duration);
|
||||
break;
|
||||
}
|
||||
});
|
||||
_mediaItemSubscription = audioHandler.mediaItem.listen((mediaItem) async {
|
||||
if (mediaItem == null) return;
|
||||
_queueIndex = getQueueIndex();
|
||||
//Load more flow if last song (not using .last since it iterates through previous elements first)
|
||||
|
||||
//Save queue
|
||||
await audioHandler.customAction('saveQueue', {});
|
||||
//Add to history
|
||||
if (cache.history.isNotEmpty && cache.history.last.id == mediaItem.id) {
|
||||
return;
|
||||
}
|
||||
cache.history.add(Track.fromMediaItem(mediaItem));
|
||||
cache.save();
|
||||
});
|
||||
|
||||
//Start audio_service
|
||||
// await startService(); it is already ready, there is no need to start it
|
||||
}
|
||||
|
||||
Future<void> authorizeLastFM() async {
|
||||
if (settings.lastFMUsername == null || settings.lastFMPassword == null) {
|
||||
return;
|
||||
}
|
||||
await audioHandler.customAction('authorizeLastFM', {
|
||||
'username': settings.lastFMUsername,
|
||||
'password': settings.lastFMPassword
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> toggleShuffle() async {
|
||||
_shuffleEnabled = !_shuffleEnabled;
|
||||
await audioHandler.setShuffleMode(_shuffleEnabled
|
||||
? AudioServiceShuffleMode.all
|
||||
: AudioServiceShuffleMode.none);
|
||||
return _shuffleEnabled;
|
||||
}
|
||||
|
||||
bool get shuffleEnabled => _shuffleEnabled;
|
||||
|
||||
//Repeat toggle
|
||||
Future changeRepeat() async {
|
||||
//Change to next repeat type
|
||||
repeatType = repeatType == AudioServiceRepeatMode.all
|
||||
? AudioServiceRepeatMode.none
|
||||
: repeatType == AudioServiceRepeatMode.none
|
||||
? AudioServiceRepeatMode.one
|
||||
: AudioServiceRepeatMode.all;
|
||||
//Set repeat type
|
||||
await audioHandler.setRepeatMode(repeatType);
|
||||
}
|
||||
|
||||
//Executed before exit
|
||||
Future onExit() async {
|
||||
_customEventSubscription.cancel();
|
||||
_playbackStateStreamSubscription.cancel();
|
||||
_mediaItemSubscription.cancel();
|
||||
}
|
||||
|
||||
//Replace queue, play specified track id
|
||||
Future<void> _loadQueuePlay(List<MediaItem> queue, String? trackId) async {
|
||||
await settings.updateAudioServiceQuality();
|
||||
|
||||
await audioHandler.customAction('setIndex', {
|
||||
'index': trackId == null ? 0 : queue.indexWhere((m) => m.id == trackId)
|
||||
});
|
||||
await audioHandler.updateQueue(queue);
|
||||
// if (queue[0].id != trackId)
|
||||
// await AudioService.skipToQueueItem(trackId);
|
||||
if (!audioHandler.playbackState.value.playing) audioHandler.play();
|
||||
}
|
||||
|
||||
//Play track from album
|
||||
Future playFromAlbum(Album album, [String? trackId]) async {
|
||||
await playFromTrackList(album.tracks!, trackId,
|
||||
QueueSource(id: album.id, text: album.title, source: 'album'));
|
||||
}
|
||||
|
||||
//Play mix by track
|
||||
Future playMix(String trackId, String trackTitle) async {
|
||||
List<Track> tracks = (await deezerAPI.playMix(trackId))!;
|
||||
playFromTrackList(
|
||||
tracks,
|
||||
tracks[0].id,
|
||||
QueueSource(
|
||||
id: trackId,
|
||||
text: '${'Mix based on'.i18n} $trackTitle',
|
||||
source: 'mix'));
|
||||
}
|
||||
|
||||
//Play from artist top tracks
|
||||
Future playFromTopTracks(
|
||||
List<Track> tracks, String trackId, Artist artist) async {
|
||||
await playFromTrackList(
|
||||
tracks,
|
||||
trackId,
|
||||
QueueSource(
|
||||
id: artist.id, text: 'Top ${artist.name}', source: 'topTracks'));
|
||||
}
|
||||
|
||||
Future playFromPlaylist(Playlist playlist, [String? trackId]) async {
|
||||
await playFromTrackList(playlist.tracks!, trackId,
|
||||
QueueSource(id: playlist.id, text: playlist.title, source: 'playlist'));
|
||||
}
|
||||
|
||||
//Play episode from show, load whole show as queue
|
||||
Future<void> playShowEpisode(Show show, List<ShowEpisode> episodes,
|
||||
{int index = 0}) async {
|
||||
QueueSource queueSource =
|
||||
QueueSource(id: show.id, text: show.name, source: 'show');
|
||||
//Generate media items
|
||||
List<MediaItem> queue =
|
||||
episodes.map<MediaItem>((e) => e.toMediaItem(show)).toList();
|
||||
|
||||
//Load and play
|
||||
// await startService(); // audioservice is ready
|
||||
await settings.updateAudioServiceQuality();
|
||||
await setQueueSource(queueSource);
|
||||
await audioHandler.customAction('setIndex', {'index': index});
|
||||
await audioHandler.updateQueue(queue);
|
||||
if (!audioHandler.playbackState.value.playing) audioHandler.play();
|
||||
}
|
||||
|
||||
//Load tracks as queue, play track id, set queue source
|
||||
Future playFromTrackList(
|
||||
List<Track?> tracks, String? trackId, QueueSource queueSource) async {
|
||||
final queue = await Future.wait(tracks
|
||||
.map<Future<MediaItem>>((track) => track!.toMediaItem())
|
||||
.toList());
|
||||
await setQueueSource(queueSource);
|
||||
await _loadQueuePlay(queue, trackId);
|
||||
}
|
||||
|
||||
//Load smart track list as queue, start from beginning
|
||||
Future playFromSmartTrackList(SmartTrackList stl) async {
|
||||
//Load from API if no tracks
|
||||
if (stl.tracks == null || stl.tracks!.isEmpty) {
|
||||
if (settings.offlineMode) {
|
||||
Fluttertoast.showToast(
|
||||
msg: "Offline mode, can't play flow or smart track lists.".i18n,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastLength: Toast.LENGTH_SHORT);
|
||||
return;
|
||||
}
|
||||
|
||||
//Flow songs cannot be accessed by smart track list call
|
||||
if (stl.id! == 'flow') {
|
||||
stl.tracks = await deezerAPI.flow(stl.flowConfig);
|
||||
} else {
|
||||
stl = await deezerAPI.smartTrackList(stl.id);
|
||||
}
|
||||
}
|
||||
QueueSource queueSource = QueueSource(
|
||||
id: stl.flowConfig ?? stl.id,
|
||||
source: (stl.id == 'flow') ? 'flow' : 'smarttracklist',
|
||||
text: stl.title ??
|
||||
((stl.id == 'flow') ? 'Flow'.i18n : 'Smart track list'.i18n));
|
||||
await playFromTrackList(stl.tracks!, stl.tracks![0].id, queueSource);
|
||||
}
|
||||
|
||||
Future setQueueSource(QueueSource queueSource) async {
|
||||
this.queueSource = queueSource;
|
||||
await audioHandler.customAction('queueSource', queueSource.toJson());
|
||||
}
|
||||
|
||||
//Reorder tracks in queue
|
||||
Future reorder(int oldIndex, int newIndex) => audioHandler
|
||||
.customAction('reorder', {'oldIndex': oldIndex, 'newIndex': newIndex});
|
||||
|
||||
//Start visualizer
|
||||
// Future startVisualizer() async {
|
||||
// await audioHandler.customAction('startVisualizer');
|
||||
// }
|
||||
|
||||
//Stop visualizer
|
||||
// Future stopVisualizer() async {
|
||||
// await audioHandler.customAction('stopVisualizer');
|
||||
// }
|
||||
}
|
||||
|
||||
class AudioPlayerTaskInitArguments {
|
||||
final bool ignoreInterruptions;
|
||||
final bool seekAsSkip;
|
||||
|
@ -380,6 +98,7 @@ class AudioPlayerTask extends BaseAudioHandler {
|
|||
StreamSubscription? _audioSessionSubscription;
|
||||
StreamSubscription? _visualizerSubscription;
|
||||
StreamSubscription? _connectivitySubscription;
|
||||
bool _isConnectivityPluginAvailable = true;
|
||||
|
||||
/// Android Auto helper class for navigation
|
||||
late final AndroidAuto _androidAuto;
|
||||
|
@ -436,6 +155,7 @@ class AudioPlayerTask extends BaseAudioHandler {
|
|||
// Linux/Windows specific options
|
||||
JustAudioMediaKit.title = 'Freezer';
|
||||
JustAudioMediaKit.protocolWhitelist = const ['http'];
|
||||
JustAudioMediaKit.mpvLogLevel = MPVLogLevel.debug;
|
||||
|
||||
_deezerAPI = initArgs.deezerAPI;
|
||||
_androidAuto = AndroidAuto(deezerAPI: _deezerAPI);
|
||||
|
@ -445,8 +165,7 @@ class AudioPlayerTask extends BaseAudioHandler {
|
|||
final session = await AudioSession.instance;
|
||||
session.configure(const AudioSessionConfiguration.music());
|
||||
|
||||
_box = await Hive.openLazyBox('playback',
|
||||
path: (await getTemporaryDirectory()).path);
|
||||
_box = await Hive.openLazyBox('playback', path: await Paths.cacheDir());
|
||||
|
||||
_player = AudioPlayer(
|
||||
handleInterruptions: !initArgs.ignoreInterruptions,
|
||||
|
@ -461,6 +180,8 @@ class AudioPlayerTask extends BaseAudioHandler {
|
|||
|
||||
//Update track index
|
||||
_player.currentIndexStream.listen((index) {
|
||||
_amountSeeked = 0;
|
||||
_amountPaused = 0;
|
||||
_timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
if (index != null && queue.value.isNotEmpty) {
|
||||
_queueIndex = index;
|
||||
|
@ -507,17 +228,11 @@ class AudioPlayerTask extends BaseAudioHandler {
|
|||
// queue.add(_queue);
|
||||
|
||||
// Determine audio quality to use
|
||||
try {
|
||||
await Connectivity().checkConnectivity().then(_determineAudioQuality);
|
||||
|
||||
if (await _determineAudioQuality()) {
|
||||
// listen for connectivity changes
|
||||
_connectivitySubscription =
|
||||
Connectivity().onConnectivityChanged.listen(_determineAudioQuality);
|
||||
} catch (e) {
|
||||
_logger.warning(
|
||||
'Couldn\'t determine connection! Falling back to other (which may use wifi quality)');
|
||||
// on error, return dummy value -- error can happen on linux if not using NetworkManager, for example
|
||||
_determineAudioQuality(ConnectivityResult.other);
|
||||
_connectivitySubscription = Connectivity()
|
||||
.onConnectivityChanged
|
||||
.listen(_determineAudioQualityByResult);
|
||||
}
|
||||
|
||||
await _loadQueueFile();
|
||||
|
@ -530,7 +245,31 @@ class AudioPlayerTask extends BaseAudioHandler {
|
|||
customEvent.add({'action': 'onLoad'});
|
||||
}
|
||||
|
||||
void _determineAudioQuality(ConnectivityResult result) {
|
||||
/// Determine the [AudioQuality] to use according to current connection
|
||||
///
|
||||
/// Returns whether the [Connectivity] plugin is available on this system or not
|
||||
Future<bool> _determineAudioQuality() async {
|
||||
if (_isConnectivityPluginAvailable) {
|
||||
try {
|
||||
await Connectivity()
|
||||
.checkConnectivity()
|
||||
.then(_determineAudioQualityByResult);
|
||||
return true;
|
||||
} catch (e) {
|
||||
_isConnectivityPluginAvailable = false;
|
||||
customEvent.add({'action': 'connectivityPlugin', 'available': false});
|
||||
}
|
||||
}
|
||||
|
||||
_logger.warning(
|
||||
'Couldn\'t determine connection! Falling back to other (which may use wifi quality)');
|
||||
// on error, return dummy value -- error can happen on linux if not using NetworkManager, for example
|
||||
_determineAudioQualityByResult(ConnectivityResult.other);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Determines the [AudioQuality] to use according to [result]
|
||||
void _determineAudioQualityByResult(ConnectivityResult result) {
|
||||
switch (result) {
|
||||
case ConnectivityResult.mobile:
|
||||
case ConnectivityResult.bluetooth:
|
||||
|
@ -543,6 +282,8 @@ class AudioPlayerTask extends BaseAudioHandler {
|
|||
default:
|
||||
_currentQuality = wifiQuality;
|
||||
}
|
||||
|
||||
print('quality: $_currentQuality');
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -674,6 +415,8 @@ class AudioPlayerTask extends BaseAudioHandler {
|
|||
}
|
||||
|
||||
Future<void> _logListenedTrack(MediaItem mediaItem) async {
|
||||
print(
|
||||
'logging: seek: $_amountSeeked, pause: $_amountPaused, timestamp: $_timestamp (elapsed: ${DateTime.now().millisecondsSinceEpoch - _timestamp!}ms)');
|
||||
if (!_shouldLogTracks) return;
|
||||
|
||||
//Log to Deezer
|
||||
|
@ -887,6 +630,8 @@ class AudioPlayerTask extends BaseAudioHandler {
|
|||
return DeezerAudioSource(
|
||||
getQuality: () => _currentQuality,
|
||||
trackId: mediaItem.id,
|
||||
trackToken: mediaItem.extras!['trackToken'] ?? '',
|
||||
trackTokenExpiration: mediaItem.extras!['trackTokenExpiration'] ?? 0,
|
||||
md5origin: playbackDetails![0],
|
||||
mediaVersion: playbackDetails[1],
|
||||
onStreamObtained: (qualityInfo) =>
|
||||
|
@ -903,6 +648,7 @@ class AudioPlayerTask extends BaseAudioHandler {
|
|||
//Isolate can't access globals
|
||||
wifiQuality = extras!['wifiQuality'] as AudioQuality;
|
||||
mobileQuality = extras['mobileQuality'] as AudioQuality;
|
||||
_determineAudioQuality();
|
||||
break;
|
||||
//Update queue source
|
||||
case 'queueSource':
|
||||
|
@ -985,6 +731,7 @@ class AudioPlayerTask extends BaseAudioHandler {
|
|||
_audioSessionSubscription?.cancel();
|
||||
_visualizerSubscription?.cancel();
|
||||
_bufferPositionSubscription?.cancel();
|
||||
_connectivitySubscription?.cancel();
|
||||
await super.stop();
|
||||
}
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
import 'dart:async';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:equalizer/equalizer.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:freezer/api/cache.dart';
|
||||
import 'package:freezer/api/deezer.dart';
|
||||
import 'package:freezer/api/definitions.dart';
|
||||
import 'package:freezer/api/player/audio_handler.dart';
|
||||
import 'package:freezer/settings.dart';
|
||||
import 'package:freezer/translations.i18n.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
class PlayerHelper {
|
||||
late StreamSubscription _customEventSubscription;
|
||||
late StreamSubscription _mediaItemSubscription;
|
||||
late StreamSubscription _playbackStateStreamSubscription;
|
||||
QueueSource? queueSource;
|
||||
AudioServiceRepeatMode repeatType = AudioServiceRepeatMode.none;
|
||||
int? audioSession;
|
||||
int? _prevAudioSession;
|
||||
bool equalizerOpen = false;
|
||||
bool _shuffleEnabled = false;
|
||||
int _queueIndex = 0;
|
||||
bool _started = false;
|
||||
|
||||
/// Whether this system supports the [Connectivity] plugin or not
|
||||
bool _isConnectivityPluginAvailable = true;
|
||||
bool get isConnectivityPluginAvailable => _isConnectivityPluginAvailable;
|
||||
|
||||
//Visualizer
|
||||
// StreamController _visualizerController = StreamController.broadcast();
|
||||
// Stream get visualizerStream => _visualizerController.stream;
|
||||
|
||||
final _streamInfoSubject = BehaviorSubject<StreamQualityInfo>();
|
||||
ValueStream<StreamQualityInfo> get streamInfo => _streamInfoSubject.stream;
|
||||
|
||||
final _bufferPositionSubject = BehaviorSubject<Duration>();
|
||||
ValueStream<Duration> get bufferPosition => _bufferPositionSubject.stream;
|
||||
|
||||
/// Find queue index by id
|
||||
///
|
||||
/// The function gets more expensive the longer the queue is and the further the element is from the beginning.
|
||||
int getQueueIndexFromId() => audioHandler.mediaItem.value == null
|
||||
? -1
|
||||
: audioHandler.queue.value
|
||||
.indexWhere((mi) => mi.id == audioHandler.mediaItem.value!.id);
|
||||
|
||||
int getQueueIndex() =>
|
||||
audioHandler.playbackState.value.queueIndex ?? getQueueIndexFromId();
|
||||
|
||||
int get queueIndex => _queueIndex;
|
||||
|
||||
Future<void> initAudioHandler() async {
|
||||
final initArgs = AudioPlayerTaskInitArguments.from(
|
||||
settings: settings, deezerAPI: deezerAPI);
|
||||
// initialize our audiohandler instance
|
||||
audioHandler = await AudioService.init(
|
||||
builder: () => AudioPlayerTask(initArgs),
|
||||
config: AudioServiceConfig(
|
||||
notificationColor: settings.primaryColor,
|
||||
androidStopForegroundOnPause: false,
|
||||
androidNotificationOngoing: false,
|
||||
androidNotificationClickStartsActivity: true,
|
||||
androidNotificationChannelDescription: 'Freezer',
|
||||
androidNotificationChannelName: 'Freezer',
|
||||
androidNotificationIcon: 'drawable/ic_logo',
|
||||
preloadArtwork: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> start() async {
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
//Subscribe to custom events
|
||||
_customEventSubscription = audioHandler.customEvent.listen((event) async {
|
||||
if (event is! Map) return;
|
||||
Logger('PlayerHelper').fine("event received: ${event['action']}");
|
||||
switch (event['action']) {
|
||||
case 'onLoad':
|
||||
//After audio_service is loaded, load queue, set quality
|
||||
await settings.updateAudioServiceQuality();
|
||||
break;
|
||||
case 'onRestore':
|
||||
//Load queueSource from isolate
|
||||
queueSource = event['queueSource'] as QueueSource;
|
||||
repeatType = event['repeatMode'] as AudioServiceRepeatMode;
|
||||
_queueIndex = getQueueIndex();
|
||||
break;
|
||||
case 'audioSession':
|
||||
if (!settings.enableEqualizer) break;
|
||||
//Save
|
||||
_prevAudioSession = audioSession;
|
||||
audioSession = event['id'];
|
||||
if (audioSession == null) break;
|
||||
//Open EQ
|
||||
if (!equalizerOpen) {
|
||||
Equalizer.open(event['id']);
|
||||
equalizerOpen = true;
|
||||
break;
|
||||
}
|
||||
//Change session id
|
||||
if (_prevAudioSession != audioSession) {
|
||||
if (_prevAudioSession != null) {
|
||||
Equalizer.removeAudioSessionId(_prevAudioSession!);
|
||||
}
|
||||
Equalizer.setAudioSessionId(audioSession!);
|
||||
}
|
||||
break;
|
||||
//Visualizer data
|
||||
// case 'visualizer':
|
||||
// _visualizerController.add(event['data']);
|
||||
// break;
|
||||
case 'streamInfo':
|
||||
Logger('PlayerHelper').fine("streamInfo received");
|
||||
_streamInfoSubject.add(event['data'] as StreamQualityInfo);
|
||||
break;
|
||||
case 'bufferPosition':
|
||||
_bufferPositionSubject.add(event['data'] as Duration);
|
||||
break;
|
||||
case 'connectivityPlugin':
|
||||
_isConnectivityPluginAvailable = event['available'] as bool;
|
||||
break;
|
||||
}
|
||||
});
|
||||
_mediaItemSubscription = audioHandler.mediaItem.listen((mediaItem) async {
|
||||
if (mediaItem == null) return;
|
||||
_queueIndex = getQueueIndex();
|
||||
//Load more flow if last song (not using .last since it iterates through previous elements first)
|
||||
|
||||
//Save queue
|
||||
await audioHandler.customAction('saveQueue', {});
|
||||
//Add to history
|
||||
if (cache.history.isNotEmpty && cache.history.last.id == mediaItem.id) {
|
||||
return;
|
||||
}
|
||||
cache.history.add(Track.fromMediaItem(mediaItem));
|
||||
cache.save();
|
||||
});
|
||||
|
||||
//Start audio_service
|
||||
// await startService(); it is already ready, there is no need to start it
|
||||
}
|
||||
|
||||
Future<void> authorizeLastFM() async {
|
||||
if (settings.lastFMUsername == null || settings.lastFMPassword == null) {
|
||||
return;
|
||||
}
|
||||
await audioHandler.customAction('authorizeLastFM', {
|
||||
'username': settings.lastFMUsername,
|
||||
'password': settings.lastFMPassword
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> toggleShuffle() async {
|
||||
_shuffleEnabled = !_shuffleEnabled;
|
||||
await audioHandler.setShuffleMode(_shuffleEnabled
|
||||
? AudioServiceShuffleMode.all
|
||||
: AudioServiceShuffleMode.none);
|
||||
return _shuffleEnabled;
|
||||
}
|
||||
|
||||
bool get shuffleEnabled => _shuffleEnabled;
|
||||
|
||||
//Repeat toggle
|
||||
Future changeRepeat() async {
|
||||
//Change to next repeat type
|
||||
repeatType = repeatType == AudioServiceRepeatMode.all
|
||||
? AudioServiceRepeatMode.none
|
||||
: repeatType == AudioServiceRepeatMode.none
|
||||
? AudioServiceRepeatMode.one
|
||||
: AudioServiceRepeatMode.all;
|
||||
//Set repeat type
|
||||
await audioHandler.setRepeatMode(repeatType);
|
||||
}
|
||||
|
||||
//Executed before exit
|
||||
Future onExit() async {
|
||||
_customEventSubscription.cancel();
|
||||
_playbackStateStreamSubscription.cancel();
|
||||
_mediaItemSubscription.cancel();
|
||||
}
|
||||
|
||||
//Replace queue, play specified track id
|
||||
Future<void> _loadQueuePlay(List<MediaItem> queue, String? trackId) async {
|
||||
await settings.updateAudioServiceQuality();
|
||||
|
||||
await audioHandler.customAction('setIndex', {
|
||||
'index': trackId == null ? 0 : queue.indexWhere((m) => m.id == trackId)
|
||||
});
|
||||
await audioHandler.updateQueue(queue);
|
||||
// if (queue[0].id != trackId)
|
||||
// await AudioService.skipToQueueItem(trackId);
|
||||
if (!audioHandler.playbackState.value.playing) audioHandler.play();
|
||||
}
|
||||
|
||||
//Play track from album
|
||||
Future playFromAlbum(Album album, [String? trackId]) async {
|
||||
await playFromTrackList(album.tracks!, trackId,
|
||||
QueueSource(id: album.id, text: album.title, source: 'album'));
|
||||
}
|
||||
|
||||
//Play mix by track
|
||||
Future playMix(String trackId, String trackTitle) async {
|
||||
List<Track> tracks = (await deezerAPI.playMix(trackId))!;
|
||||
playFromTrackList(
|
||||
tracks,
|
||||
tracks[0].id,
|
||||
QueueSource(
|
||||
id: trackId,
|
||||
text: '${'Mix based on'.i18n} $trackTitle',
|
||||
source: 'mix'));
|
||||
}
|
||||
|
||||
//Play from artist top tracks
|
||||
Future playFromTopTracks(
|
||||
List<Track> tracks, String trackId, Artist artist) async {
|
||||
await playFromTrackList(
|
||||
tracks,
|
||||
trackId,
|
||||
QueueSource(
|
||||
id: artist.id, text: 'Top ${artist.name}', source: 'topTracks'));
|
||||
}
|
||||
|
||||
Future playFromPlaylist(Playlist playlist, [String? trackId]) async {
|
||||
await playFromTrackList(playlist.tracks!, trackId,
|
||||
QueueSource(id: playlist.id, text: playlist.title, source: 'playlist'));
|
||||
}
|
||||
|
||||
//Play episode from show, load whole show as queue
|
||||
Future<void> playShowEpisode(Show show, List<ShowEpisode> episodes,
|
||||
{int index = 0}) async {
|
||||
QueueSource queueSource =
|
||||
QueueSource(id: show.id, text: show.name, source: 'show');
|
||||
//Generate media items
|
||||
List<MediaItem> queue =
|
||||
episodes.map<MediaItem>((e) => e.toMediaItem(show)).toList();
|
||||
|
||||
//Load and play
|
||||
// await startService(); // audioservice is ready
|
||||
await settings.updateAudioServiceQuality();
|
||||
await setQueueSource(queueSource);
|
||||
await audioHandler.customAction('setIndex', {'index': index});
|
||||
await audioHandler.updateQueue(queue);
|
||||
if (!audioHandler.playbackState.value.playing) audioHandler.play();
|
||||
}
|
||||
|
||||
//Load tracks as queue, play track id, set queue source
|
||||
Future playFromTrackList(
|
||||
List<Track?> tracks, String? trackId, QueueSource queueSource) async {
|
||||
final queue = await Future.wait(tracks
|
||||
.map<Future<MediaItem>>((track) => track!.toMediaItem())
|
||||
.toList());
|
||||
await setQueueSource(queueSource);
|
||||
await _loadQueuePlay(queue, trackId);
|
||||
}
|
||||
|
||||
//Load smart track list as queue, start from beginning
|
||||
Future playFromSmartTrackList(SmartTrackList stl) async {
|
||||
//Load from API if no tracks
|
||||
if (stl.tracks == null || stl.tracks!.isEmpty) {
|
||||
if (settings.offlineMode) {
|
||||
Fluttertoast.showToast(
|
||||
msg: "Offline mode, can't play flow or smart track lists.".i18n,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastLength: Toast.LENGTH_SHORT);
|
||||
return;
|
||||
}
|
||||
|
||||
//Flow songs cannot be accessed by smart track list call
|
||||
if (stl.id! == 'flow') {
|
||||
stl.tracks = await deezerAPI.flow(stl.flowConfig);
|
||||
} else {
|
||||
stl = await deezerAPI.smartTrackList(stl.id);
|
||||
}
|
||||
}
|
||||
QueueSource queueSource = QueueSource(
|
||||
id: stl.flowConfig ?? stl.id,
|
||||
source: (stl.id == 'flow') ? 'flow' : 'smarttracklist',
|
||||
text: stl.title ??
|
||||
((stl.id == 'flow') ? 'Flow'.i18n : 'Smart track list'.i18n));
|
||||
await playFromTrackList(stl.tracks!, stl.tracks![0].id, queueSource);
|
||||
}
|
||||
|
||||
Future setQueueSource(QueueSource queueSource) async {
|
||||
this.queueSource = queueSource;
|
||||
await audioHandler.customAction('queueSource', queueSource.toJson());
|
||||
}
|
||||
|
||||
//Reorder tracks in queue
|
||||
Future reorder(int oldIndex, int newIndex) => audioHandler
|
||||
.customAction('reorder', {'oldIndex': oldIndex, 'newIndex': newIndex});
|
||||
|
||||
//Start visualizer
|
||||
// Future startVisualizer() async {
|
||||
// await audioHandler.customAction('startVisualizer');
|
||||
// }
|
||||
|
||||
//Stop visualizer
|
||||
// Future stopVisualizer() async {
|
||||
// await audioHandler.customAction('stopVisualizer');
|
||||
// }
|
||||
}
|
|
@ -12,11 +12,13 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
|||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:freezer/api/cache.dart';
|
||||
import 'package:freezer/api/definitions.dart';
|
||||
import 'package:freezer/api/paths.dart';
|
||||
import 'package:freezer/page_routes/blur_slide.dart';
|
||||
import 'package:freezer/page_routes/fade.dart';
|
||||
import 'package:freezer/page_routes/scale_fade.dart';
|
||||
import 'package:freezer/type_adapters/uri.dart';
|
||||
import 'package:freezer/ui/downloads_screen.dart';
|
||||
import 'package:freezer/ui/fancy_scaffold.dart';
|
||||
import 'package:freezer/ui/library.dart';
|
||||
import 'package:freezer/ui/login_screen.dart';
|
||||
import 'package:freezer/ui/player_screen.dart';
|
||||
|
@ -35,7 +37,7 @@ import 'package:freezer/type_adapters/mediaitem.dart';
|
|||
|
||||
import 'api/deezer.dart';
|
||||
import 'api/download.dart';
|
||||
import 'api/player.dart';
|
||||
import 'api/player/audio_handler.dart';
|
||||
import 'settings.dart';
|
||||
import 'ui/home_screen.dart';
|
||||
import 'ui/player_bar.dart';
|
||||
|
@ -54,19 +56,18 @@ void main() async {
|
|||
..registerAdapter(HomePageItemAdapter())
|
||||
..registerAdapter(HomePageItemTypeAdapter())
|
||||
..registerAdapter(HomePageSectionLayoutAdapter())
|
||||
..registerAdapter(SmartTrackListAdapter())
|
||||
..registerAdapter(TrackAdapter())
|
||||
..registerAdapter(AlbumAdapter())
|
||||
..registerAdapter(ArtistAdapter())
|
||||
..registerAdapter(PlaylistAdapter())
|
||||
..registerAdapter(UserAdapter())
|
||||
..registerAdapter(DeezerImageDetailsAdapter())
|
||||
..registerAdapter(LyricsAdapter())
|
||||
..registerAdapter(LyricAdapter())
|
||||
..registerAdapter(SmartTrackListAdapter())
|
||||
..registerAdapter(PlaylistAdapter())
|
||||
..registerAdapter(ArtistAdapter())
|
||||
..registerAdapter(AlbumAdapter())
|
||||
..registerAdapter(UserAdapter())
|
||||
..registerAdapter(AlbumTypeAdapter())
|
||||
..registerAdapter(DeezerChannelAdapter())
|
||||
..registerAdapter(ShowAdapter())
|
||||
..registerAdapter(AlbumTypeAdapter())
|
||||
..registerAdapter(ColorAdapter())
|
||||
..registerAdapter(DurationAdapter())
|
||||
..registerAdapter(SortingAdapter())
|
||||
..registerAdapter(SortTypeAdapter())
|
||||
|
@ -74,6 +75,7 @@ void main() async {
|
|||
..registerAdapter(SearchHistoryItemAdapter())
|
||||
..registerAdapter(SearchHistoryItemTypeAdapter())
|
||||
..registerAdapter(CacheAdapter())
|
||||
..registerAdapter(ColorAdapter())
|
||||
..registerAdapter(DateTimeAdapter())
|
||||
..registerAdapter(MediaItemAdapter())
|
||||
..registerAdapter(AudioServiceRepeatModeAdapter())
|
||||
|
@ -84,8 +86,10 @@ void main() async {
|
|||
..registerAdapter(NavigatorRouteTypeAdapter())
|
||||
..registerAdapter(UriAdapter())
|
||||
..registerAdapter(QueueSourceAdapter())
|
||||
..registerAdapter(HomePageAdapter());
|
||||
await Hive.initFlutter();
|
||||
..registerAdapter(HomePageAdapter())
|
||||
..registerAdapter(NavigationRailAppearanceAdapter());
|
||||
|
||||
Hive.init(await Paths.dataDirectory());
|
||||
|
||||
//Initialize globals
|
||||
settings = await Settings.load();
|
||||
|
@ -647,7 +651,7 @@ class MainScreenState extends State<MainScreen>
|
|||
isDesktop = isLandscape && constraints.maxWidth > 1024;
|
||||
return FancyScaffold(
|
||||
key: _fancyScaffoldKey,
|
||||
bodyDrawer: _buildNavigationRail(isDesktop),
|
||||
navigationRail: _buildNavigationRail(isDesktop),
|
||||
bottomNavigationBar: buildBottomBar(isDesktop),
|
||||
bottomPanel: PlayerBar(
|
||||
focusNode: playerBarFocusNode,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:freezer/page_routes/basic_page_route.dart';
|
||||
import 'package:freezer/ui/animated_blur.dart';
|
||||
|
||||
|
@ -6,7 +6,7 @@ class FadePageRoute<T> extends BasicPageRoute<T> {
|
|||
@override
|
||||
final bool barrierDismissible;
|
||||
@override
|
||||
final Color? barrierColor;
|
||||
final Color barrierColor;
|
||||
@override
|
||||
final bool opaque;
|
||||
|
||||
|
@ -18,7 +18,7 @@ class FadePageRoute<T> extends BasicPageRoute<T> {
|
|||
super.transitionDuration,
|
||||
super.maintainState,
|
||||
super.settings,
|
||||
this.barrierColor,
|
||||
this.barrierColor = Colors.black38,
|
||||
this.barrierDismissible = false,
|
||||
this.opaque = true,
|
||||
});
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:freezer/api/definitions.dart';
|
||||
import 'package:freezer/api/download.dart';
|
||||
import 'package:freezer/api/player.dart';
|
||||
import 'package:freezer/api/player/audio_handler.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
@ -176,6 +178,20 @@ class Settings {
|
|||
|
||||
Settings();
|
||||
|
||||
void checkQuality(bool canStreamHQ, bool canStreamLossless) {
|
||||
if (canStreamLossless) return;
|
||||
|
||||
final maxQuality =
|
||||
canStreamHQ ? AudioQuality.MP3_320 : AudioQuality.MP3_128;
|
||||
|
||||
wifiQuality = _minQuality(wifiQuality, maxQuality);
|
||||
mobileQuality = _minQuality(mobileQuality, maxQuality);
|
||||
offlineQuality = _minQuality(offlineQuality, maxQuality);
|
||||
downloadQuality = _minQuality(downloadQuality, maxQuality);
|
||||
}
|
||||
|
||||
AudioQuality _minQuality(AudioQuality a, AudioQuality b) => a < b ? a : b;
|
||||
|
||||
ThemeData? get themeData {
|
||||
//System theme
|
||||
if (useSystemTheme) {
|
||||
|
@ -376,14 +392,38 @@ enum AudioQuality {
|
|||
ASK
|
||||
}
|
||||
|
||||
extension ToDeezerInt on AudioQuality {
|
||||
extension Deezer on AudioQuality {
|
||||
static AudioQuality fromDeezerQualityInt(int quality) {
|
||||
return const {
|
||||
1: AudioQuality.MP3_128,
|
||||
3: AudioQuality.MP3_320,
|
||||
9: AudioQuality.FLAC,
|
||||
}[quality]!;
|
||||
}
|
||||
|
||||
bool operator <(AudioQuality other) =>
|
||||
toDeezerQualityInt() < other.toDeezerQualityInt();
|
||||
bool operator >(AudioQuality other) =>
|
||||
toDeezerQualityInt() > other.toDeezerQualityInt();
|
||||
bool operator <=(AudioQuality other) =>
|
||||
toDeezerQualityInt() <= other.toDeezerQualityInt();
|
||||
bool operator >=(AudioQuality other) =>
|
||||
toDeezerQualityInt() >= other.toDeezerQualityInt();
|
||||
|
||||
int toDeezerQualityInt() {
|
||||
return const {
|
||||
AudioQuality.MP3_128: 1,
|
||||
AudioQuality.MP3_320: 3,
|
||||
AudioQuality.FLAC: 9,
|
||||
}[this] ??
|
||||
8;
|
||||
AudioQuality.MP3_128: 1,
|
||||
AudioQuality.MP3_320: 3,
|
||||
AudioQuality.FLAC: 9,
|
||||
}[this]!;
|
||||
}
|
||||
|
||||
String toDeezerQualityString() {
|
||||
return const {
|
||||
AudioQuality.MP3_128: 'MP3_128',
|
||||
AudioQuality.MP3_320: 'MP3_320',
|
||||
AudioQuality.FLAC: 'FLAC',
|
||||
}[this]!;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,482 +0,0 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'settings.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class SettingsAdapter extends TypeAdapter<Settings> {
|
||||
@override
|
||||
final int typeId = 24;
|
||||
|
||||
@override
|
||||
Settings read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return Settings()
|
||||
..language = fields[0] as String?
|
||||
..ignoreInterruptions = fields[1] as bool
|
||||
..enableEqualizer = fields[2] as bool
|
||||
..arl = fields[3] as String?
|
||||
..wifiQuality = fields[4] as AudioQuality
|
||||
..mobileQuality = fields[5] as AudioQuality
|
||||
..offlineQuality = fields[6] as AudioQuality
|
||||
..downloadQuality = fields[7] as AudioQuality
|
||||
..downloadPath = fields[8] as String?
|
||||
..downloadFilename = fields[9] as String
|
||||
..albumFolder = fields[10] as bool
|
||||
..artistFolder = fields[11] as bool
|
||||
..albumDiscFolder = fields[12] as bool
|
||||
..overwriteDownload = fields[13] as bool
|
||||
..downloadThreads = fields[14] as int
|
||||
..playlistFolder = fields[15] as bool
|
||||
..downloadLyrics = fields[16] as bool
|
||||
..trackCover = fields[17] as bool
|
||||
..albumCover = fields[18] as bool
|
||||
..nomediaFiles = fields[19] as bool
|
||||
..artistSeparator = fields[20] as String
|
||||
..singletonFilename = fields[21] as String
|
||||
..albumArtResolution = fields[22] as int
|
||||
..tags = (fields[23] as List).cast<String>()
|
||||
..theme = fields[24] as Themes
|
||||
..useSystemTheme = fields[25] as bool
|
||||
..colorGradientBackground = fields[26] as bool
|
||||
..blurPlayerBackground = fields[27] as bool
|
||||
..font = fields[28] as String
|
||||
..lyricsVisualizer = fields[29] as bool
|
||||
..displayMode = fields[30] as int?
|
||||
..enableFilledPlayButton = fields[31] == null ? true : fields[31] as bool
|
||||
..playerBackgroundOnLyrics =
|
||||
fields[32] == null ? false : fields[32] as bool
|
||||
..navigatorRouteType = fields[33] == null
|
||||
? NavigatorRouteType.material
|
||||
: fields[33] as NavigatorRouteType
|
||||
..primaryColor = fields[34] == null ? Colors.blue : fields[34] as Color
|
||||
..useArtColor = fields[35] as bool
|
||||
..deezerLanguage = fields[36] as String
|
||||
..deezerCountry = fields[37] as String
|
||||
..logListen = fields[38] as bool
|
||||
..proxyAddress = fields[39] as String?
|
||||
..lastFMUsername = fields[40] as String?
|
||||
..lastFMPassword = fields[41] as String?
|
||||
..spotifyClientId = fields[42] as String?
|
||||
..spotifyClientSecret = fields[43] as String?
|
||||
..spotifyCredentials = fields[44] as SpotifyCredentialsSave?
|
||||
..materialYouAccent = fields[45] == null ? false : fields[45] as bool
|
||||
..playerAlbumArtDropShadow =
|
||||
fields[46] == null ? true : fields[46] as bool
|
||||
..seekAsSkip = fields[47] == null ? false : fields[47] as bool;
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, Settings obj) {
|
||||
writer
|
||||
..writeByte(48)
|
||||
..writeByte(0)
|
||||
..write(obj.language)
|
||||
..writeByte(1)
|
||||
..write(obj.ignoreInterruptions)
|
||||
..writeByte(2)
|
||||
..write(obj.enableEqualizer)
|
||||
..writeByte(3)
|
||||
..write(obj.arl)
|
||||
..writeByte(4)
|
||||
..write(obj.wifiQuality)
|
||||
..writeByte(5)
|
||||
..write(obj.mobileQuality)
|
||||
..writeByte(6)
|
||||
..write(obj.offlineQuality)
|
||||
..writeByte(7)
|
||||
..write(obj.downloadQuality)
|
||||
..writeByte(8)
|
||||
..write(obj.downloadPath)
|
||||
..writeByte(9)
|
||||
..write(obj.downloadFilename)
|
||||
..writeByte(10)
|
||||
..write(obj.albumFolder)
|
||||
..writeByte(11)
|
||||
..write(obj.artistFolder)
|
||||
..writeByte(12)
|
||||
..write(obj.albumDiscFolder)
|
||||
..writeByte(13)
|
||||
..write(obj.overwriteDownload)
|
||||
..writeByte(14)
|
||||
..write(obj.downloadThreads)
|
||||
..writeByte(15)
|
||||
..write(obj.playlistFolder)
|
||||
..writeByte(16)
|
||||
..write(obj.downloadLyrics)
|
||||
..writeByte(17)
|
||||
..write(obj.trackCover)
|
||||
..writeByte(18)
|
||||
..write(obj.albumCover)
|
||||
..writeByte(19)
|
||||
..write(obj.nomediaFiles)
|
||||
..writeByte(20)
|
||||
..write(obj.artistSeparator)
|
||||
..writeByte(21)
|
||||
..write(obj.singletonFilename)
|
||||
..writeByte(22)
|
||||
..write(obj.albumArtResolution)
|
||||
..writeByte(23)
|
||||
..write(obj.tags)
|
||||
..writeByte(24)
|
||||
..write(obj.theme)
|
||||
..writeByte(25)
|
||||
..write(obj.useSystemTheme)
|
||||
..writeByte(26)
|
||||
..write(obj.colorGradientBackground)
|
||||
..writeByte(27)
|
||||
..write(obj.blurPlayerBackground)
|
||||
..writeByte(28)
|
||||
..write(obj.font)
|
||||
..writeByte(29)
|
||||
..write(obj.lyricsVisualizer)
|
||||
..writeByte(30)
|
||||
..write(obj.displayMode)
|
||||
..writeByte(31)
|
||||
..write(obj.enableFilledPlayButton)
|
||||
..writeByte(32)
|
||||
..write(obj.playerBackgroundOnLyrics)
|
||||
..writeByte(33)
|
||||
..write(obj.navigatorRouteType)
|
||||
..writeByte(34)
|
||||
..write(obj.primaryColor)
|
||||
..writeByte(35)
|
||||
..write(obj.useArtColor)
|
||||
..writeByte(36)
|
||||
..write(obj.deezerLanguage)
|
||||
..writeByte(37)
|
||||
..write(obj.deezerCountry)
|
||||
..writeByte(38)
|
||||
..write(obj.logListen)
|
||||
..writeByte(39)
|
||||
..write(obj.proxyAddress)
|
||||
..writeByte(40)
|
||||
..write(obj.lastFMUsername)
|
||||
..writeByte(41)
|
||||
..write(obj.lastFMPassword)
|
||||
..writeByte(42)
|
||||
..write(obj.spotifyClientId)
|
||||
..writeByte(43)
|
||||
..write(obj.spotifyClientSecret)
|
||||
..writeByte(44)
|
||||
..write(obj.spotifyCredentials)
|
||||
..writeByte(45)
|
||||
..write(obj.materialYouAccent)
|
||||
..writeByte(46)
|
||||
..write(obj.playerAlbumArtDropShadow)
|
||||
..writeByte(47)
|
||||
..write(obj.seekAsSkip);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SettingsAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
|
||||
class SpotifyCredentialsSaveAdapter
|
||||
extends TypeAdapter<SpotifyCredentialsSave> {
|
||||
@override
|
||||
final int typeId = 25;
|
||||
|
||||
@override
|
||||
SpotifyCredentialsSave read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return SpotifyCredentialsSave(
|
||||
accessToken: fields[0] as String?,
|
||||
refreshToken: fields[1] as String?,
|
||||
scopes: (fields[2] as List?)?.cast<String>(),
|
||||
expiration: fields[3] as DateTime?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, SpotifyCredentialsSave obj) {
|
||||
writer
|
||||
..writeByte(4)
|
||||
..writeByte(0)
|
||||
..write(obj.accessToken)
|
||||
..writeByte(1)
|
||||
..write(obj.refreshToken)
|
||||
..writeByte(2)
|
||||
..write(obj.scopes)
|
||||
..writeByte(3)
|
||||
..write(obj.expiration);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SpotifyCredentialsSaveAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
|
||||
class AudioQualityAdapter extends TypeAdapter<AudioQuality> {
|
||||
@override
|
||||
final int typeId = 29;
|
||||
|
||||
@override
|
||||
AudioQuality read(BinaryReader reader) {
|
||||
switch (reader.readByte()) {
|
||||
case 0:
|
||||
return AudioQuality.MP3_128;
|
||||
case 1:
|
||||
return AudioQuality.MP3_320;
|
||||
case 2:
|
||||
return AudioQuality.FLAC;
|
||||
case 3:
|
||||
return AudioQuality.ASK;
|
||||
default:
|
||||
return AudioQuality.MP3_128;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, AudioQuality obj) {
|
||||
switch (obj) {
|
||||
case AudioQuality.MP3_128:
|
||||
writer.writeByte(0);
|
||||
break;
|
||||
case AudioQuality.MP3_320:
|
||||
writer.writeByte(1);
|
||||
break;
|
||||
case AudioQuality.FLAC:
|
||||
writer.writeByte(2);
|
||||
break;
|
||||
case AudioQuality.ASK:
|
||||
writer.writeByte(3);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is AudioQualityAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
|
||||
class ThemesAdapter extends TypeAdapter<Themes> {
|
||||
@override
|
||||
final int typeId = 28;
|
||||
|
||||
@override
|
||||
Themes read(BinaryReader reader) {
|
||||
switch (reader.readByte()) {
|
||||
case 0:
|
||||
return Themes.Light;
|
||||
case 1:
|
||||
return Themes.Dark;
|
||||
case 2:
|
||||
return Themes.Deezer;
|
||||
case 3:
|
||||
return Themes.Black;
|
||||
default:
|
||||
return Themes.Light;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, Themes obj) {
|
||||
switch (obj) {
|
||||
case Themes.Light:
|
||||
writer.writeByte(0);
|
||||
break;
|
||||
case Themes.Dark:
|
||||
writer.writeByte(1);
|
||||
break;
|
||||
case Themes.Deezer:
|
||||
writer.writeByte(2);
|
||||
break;
|
||||
case Themes.Black:
|
||||
writer.writeByte(3);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ThemesAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
Settings _$SettingsFromJson(Map<String, dynamic> json) => Settings()
|
||||
..language = json['language'] as String?
|
||||
..ignoreInterruptions = json['ignoreInterruptions'] as bool
|
||||
..enableEqualizer = json['enableEqualizer'] as bool
|
||||
..arl = json['arl'] as String?
|
||||
..offlineMode = json['offlineMode'] as bool
|
||||
..wifiQuality = $enumDecode(_$AudioQualityEnumMap, json['wifiQuality'])
|
||||
..mobileQuality = $enumDecode(_$AudioQualityEnumMap, json['mobileQuality'])
|
||||
..offlineQuality = $enumDecode(_$AudioQualityEnumMap, json['offlineQuality'])
|
||||
..downloadQuality =
|
||||
$enumDecode(_$AudioQualityEnumMap, json['downloadQuality'])
|
||||
..downloadPath = json['downloadPath'] as String?
|
||||
..downloadFilename = json['downloadFilename'] as String
|
||||
..albumFolder = json['albumFolder'] as bool
|
||||
..artistFolder = json['artistFolder'] as bool
|
||||
..albumDiscFolder = json['albumDiscFolder'] as bool
|
||||
..overwriteDownload = json['overwriteDownload'] as bool
|
||||
..downloadThreads = json['downloadThreads'] as int
|
||||
..playlistFolder = json['playlistFolder'] as bool
|
||||
..downloadLyrics = json['downloadLyrics'] as bool
|
||||
..trackCover = json['trackCover'] as bool
|
||||
..albumCover = json['albumCover'] as bool
|
||||
..nomediaFiles = json['nomediaFiles'] as bool
|
||||
..artistSeparator = json['artistSeparator'] as String
|
||||
..singletonFilename = json['singletonFilename'] as String
|
||||
..albumArtResolution = json['albumArtResolution'] as int
|
||||
..tags = (json['tags'] as List<dynamic>).map((e) => e as String).toList()
|
||||
..theme = $enumDecode(_$ThemesEnumMap, json['theme'])
|
||||
..useSystemTheme = json['useSystemTheme'] as bool
|
||||
..colorGradientBackground = json['colorGradientBackground'] as bool
|
||||
..blurPlayerBackground = json['blurPlayerBackground'] as bool
|
||||
..font = json['font'] as String
|
||||
..lyricsVisualizer = json['lyricsVisualizer'] as bool
|
||||
..displayMode = json['displayMode'] as int?
|
||||
..enableFilledPlayButton = json['enableFilledPlayButton'] as bool
|
||||
..playerBackgroundOnLyrics = json['playerBackgroundOnLyrics'] as bool
|
||||
..navigatorRouteType =
|
||||
$enumDecode(_$NavigatorRouteTypeEnumMap, json['navigatorRouteType'])
|
||||
..primaryColor = Settings._colorFromJson(json['primaryColor'] as int?)
|
||||
..useArtColor = json['useArtColor'] as bool
|
||||
..deezerLanguage = json['deezerLanguage'] as String
|
||||
..deezerCountry = json['deezerCountry'] as String
|
||||
..logListen = json['logListen'] as bool
|
||||
..proxyAddress = json['proxyAddress'] as String?
|
||||
..lastFMUsername = json['lastFMUsername'] as String?
|
||||
..lastFMPassword = json['lastFMPassword'] as String?
|
||||
..spotifyClientId = json['spotifyClientId'] as String?
|
||||
..spotifyClientSecret = json['spotifyClientSecret'] as String?
|
||||
..spotifyCredentials = json['spotifyCredentials'] == null
|
||||
? null
|
||||
: SpotifyCredentialsSave.fromJson(
|
||||
json['spotifyCredentials'] as Map<String, dynamic>)
|
||||
..materialYouAccent = json['materialYouAccent'] as bool
|
||||
..playerAlbumArtDropShadow = json['playerAlbumArtDropShadow'] as bool
|
||||
..seekAsSkip = json['seekAsSkip'] as bool;
|
||||
|
||||
Map<String, dynamic> _$SettingsToJson(Settings instance) => <String, dynamic>{
|
||||
'language': instance.language,
|
||||
'ignoreInterruptions': instance.ignoreInterruptions,
|
||||
'enableEqualizer': instance.enableEqualizer,
|
||||
'arl': instance.arl,
|
||||
'wifiQuality': _$AudioQualityEnumMap[instance.wifiQuality]!,
|
||||
'mobileQuality': _$AudioQualityEnumMap[instance.mobileQuality]!,
|
||||
'offlineQuality': _$AudioQualityEnumMap[instance.offlineQuality]!,
|
||||
'downloadQuality': _$AudioQualityEnumMap[instance.downloadQuality]!,
|
||||
'downloadPath': instance.downloadPath,
|
||||
'downloadFilename': instance.downloadFilename,
|
||||
'albumFolder': instance.albumFolder,
|
||||
'artistFolder': instance.artistFolder,
|
||||
'albumDiscFolder': instance.albumDiscFolder,
|
||||
'overwriteDownload': instance.overwriteDownload,
|
||||
'downloadThreads': instance.downloadThreads,
|
||||
'playlistFolder': instance.playlistFolder,
|
||||
'downloadLyrics': instance.downloadLyrics,
|
||||
'trackCover': instance.trackCover,
|
||||
'albumCover': instance.albumCover,
|
||||
'nomediaFiles': instance.nomediaFiles,
|
||||
'artistSeparator': instance.artistSeparator,
|
||||
'singletonFilename': instance.singletonFilename,
|
||||
'albumArtResolution': instance.albumArtResolution,
|
||||
'tags': instance.tags,
|
||||
'theme': _$ThemesEnumMap[instance.theme]!,
|
||||
'useSystemTheme': instance.useSystemTheme,
|
||||
'colorGradientBackground': instance.colorGradientBackground,
|
||||
'blurPlayerBackground': instance.blurPlayerBackground,
|
||||
'font': instance.font,
|
||||
'lyricsVisualizer': instance.lyricsVisualizer,
|
||||
'displayMode': instance.displayMode,
|
||||
'enableFilledPlayButton': instance.enableFilledPlayButton,
|
||||
'playerBackgroundOnLyrics': instance.playerBackgroundOnLyrics,
|
||||
'navigatorRouteType':
|
||||
_$NavigatorRouteTypeEnumMap[instance.navigatorRouteType]!,
|
||||
'primaryColor': Settings._colorToJson(instance.primaryColor),
|
||||
'useArtColor': instance.useArtColor,
|
||||
'deezerLanguage': instance.deezerLanguage,
|
||||
'deezerCountry': instance.deezerCountry,
|
||||
'logListen': instance.logListen,
|
||||
'proxyAddress': instance.proxyAddress,
|
||||
'lastFMUsername': instance.lastFMUsername,
|
||||
'lastFMPassword': instance.lastFMPassword,
|
||||
'spotifyClientId': instance.spotifyClientId,
|
||||
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||
'spotifyCredentials': instance.spotifyCredentials?.toJson(),
|
||||
'materialYouAccent': instance.materialYouAccent,
|
||||
'playerAlbumArtDropShadow': instance.playerAlbumArtDropShadow,
|
||||
'seekAsSkip': instance.seekAsSkip,
|
||||
};
|
||||
|
||||
const _$AudioQualityEnumMap = {
|
||||
AudioQuality.MP3_128: 'MP3_128',
|
||||
AudioQuality.MP3_320: 'MP3_320',
|
||||
AudioQuality.FLAC: 'FLAC',
|
||||
AudioQuality.ASK: 'ASK',
|
||||
};
|
||||
|
||||
const _$ThemesEnumMap = {
|
||||
Themes.Light: 'Light',
|
||||
Themes.Dark: 'Dark',
|
||||
Themes.Deezer: 'Deezer',
|
||||
Themes.Black: 'Black',
|
||||
};
|
||||
|
||||
const _$NavigatorRouteTypeEnumMap = {
|
||||
NavigatorRouteType.blur_slide: 'blur_slide',
|
||||
NavigatorRouteType.fade: 'fade',
|
||||
NavigatorRouteType.fade_blur: 'fade_blur',
|
||||
NavigatorRouteType.material: 'material',
|
||||
NavigatorRouteType.cupertino: 'cupertino',
|
||||
};
|
||||
|
||||
SpotifyCredentialsSave _$SpotifyCredentialsSaveFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
SpotifyCredentialsSave(
|
||||
accessToken: json['accessToken'] as String?,
|
||||
refreshToken: json['refreshToken'] as String?,
|
||||
scopes:
|
||||
(json['scopes'] as List<dynamic>?)?.map((e) => e as String).toList(),
|
||||
expiration: json['expiration'] == null
|
||||
? null
|
||||
: DateTime.parse(json['expiration'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SpotifyCredentialsSaveToJson(
|
||||
SpotifyCredentialsSave instance) =>
|
||||
<String, dynamic>{
|
||||
'accessToken': instance.accessToken,
|
||||
'refreshToken': instance.refreshToken,
|
||||
'scopes': instance.scopes,
|
||||
'expiration': instance.expiration?.toIso8601String(),
|
||||
};
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:freezer/api/deezer.dart';
|
||||
import 'package:freezer/api/definitions.dart';
|
||||
import 'package:freezer/api/player.dart';
|
||||
import 'package:freezer/api/player/audio_handler.dart';
|
||||
import 'package:freezer/translations.i18n.dart';
|
||||
|
||||
class AndroidAuto {
|
||||
|
|
|
@ -8,7 +8,7 @@ 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.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';
|
||||
|
@ -66,6 +66,9 @@ class _AlbumDetailsState extends State<AlbumDetails> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(album!.title ?? ''),
|
||||
),
|
||||
body: _error
|
||||
? const ErrorScreen()
|
||||
: _loading
|
||||
|
@ -314,20 +317,17 @@ class ArtistDetails extends StatefulWidget {
|
|||
|
||||
class _ArtistDetailsState extends State<ArtistDetails> {
|
||||
late final Future<Artist> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
FutureOr<Artist> future = _loadArtist(widget.artist);
|
||||
if (future is Artist) {
|
||||
_future = Future.value(widget.artist);
|
||||
} else {
|
||||
_future = future;
|
||||
}
|
||||
_future = _loadArtist(widget.artist);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
FutureOr<Artist> _loadArtist(Artist artist) {
|
||||
Future<Artist> _loadArtist(Artist artist) async {
|
||||
//Load artist from api if no albums
|
||||
if ((artist.albums ?? []).isEmpty) {
|
||||
return deezerAPI.artist(artist.id);
|
||||
return await deezerAPI.artist(artist.id);
|
||||
}
|
||||
|
||||
return artist;
|
||||
|
@ -336,6 +336,7 @@ class _ArtistDetailsState extends State<ArtistDetails> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.artist.name ?? '')),
|
||||
body: FutureBuilder<Artist>(
|
||||
future: _future,
|
||||
builder: (BuildContext context, snapshot) {
|
||||
|
@ -424,14 +425,9 @@ class _ArtistDetailsState extends State<ArtistDetails> {
|
|||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: <Widget>[
|
||||
TextButton(
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const Icon(Icons.favorite, size: 32),
|
||||
const SizedBox(width: 4.0),
|
||||
Text('Library'.i18n)
|
||||
],
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.favorite),
|
||||
label: Text('Library'.i18n),
|
||||
onPressed: () async {
|
||||
await deezerAPI.addFavoriteArtist(widget.artist.id);
|
||||
ScaffoldMessenger.of(context)
|
||||
|
@ -439,14 +435,9 @@ class _ArtistDetailsState extends State<ArtistDetails> {
|
|||
},
|
||||
),
|
||||
if ((artist.radio ?? false))
|
||||
TextButton(
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const Icon(Icons.radio, size: 32),
|
||||
const SizedBox(width: 4.0),
|
||||
Text('Radio'.i18n)
|
||||
],
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.radio),
|
||||
label: Text('Radio'.i18n),
|
||||
onPressed: () async {
|
||||
List<Track> tracks =
|
||||
(await deezerAPI.smartRadio(artist.id))!;
|
||||
|
@ -873,238 +864,246 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(playlist!.title ?? '')),
|
||||
body: DraggableScrollbar.rrect(
|
||||
controller: _scrollController,
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
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,
|
||||
controller: _scrollController,
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
child: ListView(
|
||||
controller: _scrollController,
|
||||
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,
|
||||
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),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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);
|
||||
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()),
|
||||
//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,
|
||||
),
|
||||
),
|
||||
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()),
|
||||
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)
|
||||
],
|
||||
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,
|
||||
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()],
|
||||
),
|
||||
),
|
||||
onPressed: () => _reverse(),
|
||||
),
|
||||
Container(width: 4.0)
|
||||
if (_error) const ErrorScreen()
|
||||
],
|
||||
),
|
||||
const FreezerDivider(),
|
||||
...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()
|
||||
],
|
||||
),
|
||||
));
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,195 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class FancyScaffold extends StatefulWidget {
|
||||
final Widget bottomPanel;
|
||||
final double bottomPanelHeight;
|
||||
final Widget expandedPanel;
|
||||
final Widget? bottomNavigationBar;
|
||||
final Widget? drawer;
|
||||
final Widget? navigationRail;
|
||||
final Widget body;
|
||||
final void Function(AnimationStatus)? onAnimationStatusChange;
|
||||
|
||||
const FancyScaffold({
|
||||
required this.bottomPanel,
|
||||
required this.bottomPanelHeight,
|
||||
required this.expandedPanel,
|
||||
required this.body,
|
||||
this.onAnimationStatusChange,
|
||||
this.bottomNavigationBar,
|
||||
this.navigationRail,
|
||||
this.drawer,
|
||||
super.key,
|
||||
});
|
||||
|
||||
static FancyScaffoldState? of(BuildContext context) =>
|
||||
context.findAncestorStateOfType<FancyScaffoldState>();
|
||||
|
||||
@override
|
||||
FancyScaffoldState createState() => FancyScaffoldState();
|
||||
}
|
||||
|
||||
class FancyScaffoldState extends State<FancyScaffold>
|
||||
with TickerProviderStateMixin {
|
||||
// goes from 0 to 1 (double)
|
||||
// 0 = preview, 1 = expanded
|
||||
late final AnimationController dragController;
|
||||
final statusNotifier =
|
||||
ValueNotifier<AnimationStatus>(AnimationStatus.dismissed);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
dragController = AnimationController(
|
||||
vsync: this, duration: const Duration(milliseconds: 500));
|
||||
dragController.addStatusListener((status) => statusNotifier.value = status);
|
||||
statusNotifier.addListener(
|
||||
() => widget.onAnimationStatusChange?.call(statusNotifier.value));
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
dragController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final systemPadding = MediaQuery.of(context).viewPadding;
|
||||
final defaultBottomPadding =
|
||||
(widget.bottomNavigationBar == null ? 0 : 80.0) + systemPadding.bottom;
|
||||
final screenHeight = MediaQuery.of(context).size.height;
|
||||
final sizeAnimation = Tween<double>(
|
||||
begin: widget.bottomPanelHeight / MediaQuery.of(context).size.height,
|
||||
end: 1.0,
|
||||
).animate(dragController);
|
||||
return WillPopScope(
|
||||
onWillPop: () {
|
||||
if (statusNotifier.value == AnimationStatus.completed ||
|
||||
statusNotifier.value == AnimationStatus.reverse) {
|
||||
dragController.fling(velocity: -1.0);
|
||||
return Future.value(false);
|
||||
}
|
||||
|
||||
return Future.value(true);
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Scaffold(
|
||||
body: widget.navigationRail != null
|
||||
? Row(children: [
|
||||
widget.navigationRail!,
|
||||
const VerticalDivider(
|
||||
indent: 0.0,
|
||||
endIndent: 0.0,
|
||||
width: 2.0,
|
||||
),
|
||||
Expanded(child: widget.body)
|
||||
])
|
||||
: widget.body,
|
||||
drawer: widget.drawer,
|
||||
bottomNavigationBar: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(height: widget.bottomPanelHeight),
|
||||
if (widget.bottomNavigationBar != null)
|
||||
SizeTransition(
|
||||
axisAlignment: -1.0,
|
||||
sizeFactor:
|
||||
Tween(begin: 1.0, end: 0.0).animate(sizeAnimation),
|
||||
child: widget.bottomNavigationBar,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: AnimatedBuilder(
|
||||
animation: sizeAnimation,
|
||||
builder: (context, child) {
|
||||
final x = 1.0 - sizeAnimation.value;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: (defaultBottomPadding /*+ 8.0*/) * x,
|
||||
//right: 8.0 * x,
|
||||
//left: 8.0 * x,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: statusNotifier,
|
||||
builder: (context, state, child) {
|
||||
return GestureDetector(
|
||||
onVerticalDragEnd: _onVerticalDragEnd,
|
||||
onVerticalDragUpdate: _onVerticalDragUpdate,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: SizeTransition(
|
||||
sizeFactor: sizeAnimation,
|
||||
axisAlignment: -1.0,
|
||||
axis: Axis.vertical,
|
||||
child: SizedBox(
|
||||
height: screenHeight,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: statusNotifier,
|
||||
builder: (context, state, _) => Stack(
|
||||
children: [
|
||||
if (state != AnimationStatus.dismissed)
|
||||
Positioned.fill(
|
||||
key: const Key('player_screen'),
|
||||
child: widget.expandedPanel,
|
||||
),
|
||||
if (state != AnimationStatus.completed)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
key: const Key('player_bar'),
|
||||
child: FadeTransition(
|
||||
opacity: Tween(begin: 1.0, end: 0.0)
|
||||
.animate(dragController),
|
||||
child: SizedBox(
|
||||
height: widget.bottomPanelHeight,
|
||||
child: widget.bottomPanel),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onVerticalDragUpdate(DragUpdateDetails details) {
|
||||
dragController.value -=
|
||||
details.delta.dy / MediaQuery.of(context).size.height;
|
||||
}
|
||||
|
||||
void _onVerticalDragEnd(DragEndDetails details) {
|
||||
// snap widget to size
|
||||
// this should be also handled by drag velocity and not only with bare size.
|
||||
|
||||
const double minFlingVelocity = 365.0;
|
||||
|
||||
if (details.velocity.pixelsPerSecond.dy.abs() > minFlingVelocity) {
|
||||
dragController.fling(
|
||||
velocity: -details.velocity.pixelsPerSecond.dy /
|
||||
MediaQuery.of(context).size.height);
|
||||
return;
|
||||
}
|
||||
|
||||
dragController.fling(velocity: dragController.value > 0.5 ? 1.0 : -1.0);
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:freezer/api/deezer.dart';
|
||||
import 'package:freezer/api/definitions.dart';
|
||||
import 'package:freezer/api/player.dart';
|
||||
import 'package:freezer/api/player/audio_handler.dart';
|
||||
import 'package:freezer/ui/error.dart';
|
||||
import 'package:freezer/ui/menu.dart';
|
||||
import 'package:freezer/translations.i18n.dart';
|
||||
|
|
|
@ -5,7 +5,7 @@ import 'package:freezer/api/cache.dart';
|
|||
import 'package:freezer/api/deezer.dart';
|
||||
import 'package:freezer/api/definitions.dart';
|
||||
import 'package:freezer/api/importer.dart';
|
||||
import 'package:freezer/api/player.dart';
|
||||
import 'package:freezer/api/player/audio_handler.dart';
|
||||
import 'package:freezer/settings.dart';
|
||||
import 'package:freezer/ui/details_screens.dart';
|
||||
import 'package:freezer/ui/elements.dart';
|
||||
|
|
|
@ -6,7 +6,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:freezer/api/deezer.dart';
|
||||
import 'package:freezer/api/definitions.dart';
|
||||
import 'package:freezer/api/player.dart';
|
||||
import 'package:freezer/api/player/audio_handler.dart';
|
||||
import 'package:freezer/settings.dart';
|
||||
import 'package:freezer/translations.i18n.dart';
|
||||
import 'package:freezer/ui/error.dart';
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:freezer/main.dart';
|
||||
import 'package:freezer/ui/fancy_scaffold.dart';
|
||||
import 'package:freezer/ui/player_bar.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:freezer/api/cache.dart';
|
||||
import 'package:freezer/api/deezer.dart';
|
||||
import 'package:freezer/api/download.dart';
|
||||
import 'package:freezer/api/player.dart';
|
||||
import 'package:freezer/api/player/audio_handler.dart';
|
||||
import 'package:freezer/ui/details_screens.dart';
|
||||
import 'package:freezer/ui/error.dart';
|
||||
import 'package:freezer/translations.i18n.dart';
|
||||
|
@ -117,7 +118,7 @@ class MenuSheet {
|
|||
showMenu(
|
||||
elevation: 4.0,
|
||||
context: context,
|
||||
constraints: const BoxConstraints(maxWidth: 300),
|
||||
constraints: const BoxConstraints(maxWidth: 300.0),
|
||||
position:
|
||||
RelativeRect.fromSize(actualPosition & Size.zero, overlay.size),
|
||||
items: options
|
||||
|
@ -128,7 +129,7 @@ class MenuSheet {
|
|||
: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
option.icon!,
|
||||
const SizedBox(width: 8.0),
|
||||
option.label,
|
||||
Flexible(child: option.label),
|
||||
])))
|
||||
.toList(growable: false));
|
||||
}
|
||||
|
|
|
@ -4,200 +4,12 @@ import 'package:audio_service/audio_service.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:freezer/settings.dart';
|
||||
import 'package:freezer/translations.i18n.dart';
|
||||
import 'package:freezer/ui/fancy_scaffold.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
import '../api/player.dart';
|
||||
import '../api/player/audio_handler.dart';
|
||||
import 'cached_image.dart';
|
||||
|
||||
class FancyScaffold extends StatefulWidget {
|
||||
final Widget bottomPanel;
|
||||
final double bottomPanelHeight;
|
||||
final Widget expandedPanel;
|
||||
final Widget? bottomNavigationBar;
|
||||
final Widget? drawer;
|
||||
final Widget? bodyDrawer;
|
||||
final Widget body;
|
||||
final void Function(AnimationStatus)? onAnimationStatusChange;
|
||||
|
||||
const FancyScaffold({
|
||||
required this.bottomPanel,
|
||||
required this.bottomPanelHeight,
|
||||
required this.expandedPanel,
|
||||
required this.body,
|
||||
this.onAnimationStatusChange,
|
||||
this.bottomNavigationBar,
|
||||
this.bodyDrawer,
|
||||
this.drawer,
|
||||
super.key,
|
||||
});
|
||||
|
||||
static FancyScaffoldState? of(BuildContext context) =>
|
||||
context.findAncestorStateOfType<FancyScaffoldState>();
|
||||
|
||||
@override
|
||||
FancyScaffoldState createState() => FancyScaffoldState();
|
||||
}
|
||||
|
||||
class FancyScaffoldState extends State<FancyScaffold>
|
||||
with TickerProviderStateMixin {
|
||||
// goes from 0 to 1 (double)
|
||||
// 0 = preview, 1 = expanded
|
||||
late final AnimationController dragController;
|
||||
final statusNotifier =
|
||||
ValueNotifier<AnimationStatus>(AnimationStatus.dismissed);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
dragController = AnimationController(
|
||||
vsync: this, duration: const Duration(milliseconds: 500));
|
||||
dragController.addStatusListener((status) => statusNotifier.value = status);
|
||||
statusNotifier.addListener(
|
||||
() => widget.onAnimationStatusChange?.call(statusNotifier.value));
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
dragController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final systemPadding = MediaQuery.of(context).viewPadding;
|
||||
final defaultBottomPadding =
|
||||
(widget.bottomNavigationBar == null ? 0 : 80.0) + systemPadding.bottom;
|
||||
final screenHeight = MediaQuery.of(context).size.height;
|
||||
final sizeAnimation = Tween<double>(
|
||||
begin: widget.bottomPanelHeight / MediaQuery.of(context).size.height,
|
||||
end: 1.0,
|
||||
).animate(dragController);
|
||||
return WillPopScope(
|
||||
onWillPop: () {
|
||||
if (statusNotifier.value == AnimationStatus.completed ||
|
||||
statusNotifier.value == AnimationStatus.reverse) {
|
||||
dragController.fling(velocity: -1.0);
|
||||
return Future.value(false);
|
||||
}
|
||||
|
||||
return Future.value(true);
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Scaffold(
|
||||
body: widget.bodyDrawer != null
|
||||
? Row(children: [
|
||||
widget.bodyDrawer!,
|
||||
Expanded(child: widget.body)
|
||||
])
|
||||
: widget.body,
|
||||
drawer: widget.drawer,
|
||||
bottomNavigationBar: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(height: widget.bottomPanelHeight),
|
||||
if (widget.bottomNavigationBar != null)
|
||||
SizeTransition(
|
||||
axisAlignment: -1.0,
|
||||
sizeFactor:
|
||||
Tween(begin: 1.0, end: 0.0).animate(sizeAnimation),
|
||||
child: widget.bottomNavigationBar,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: AnimatedBuilder(
|
||||
animation: sizeAnimation,
|
||||
builder: (context, child) {
|
||||
final x = 1.0 - sizeAnimation.value;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: (defaultBottomPadding /*+ 8.0*/) * x,
|
||||
//right: 8.0 * x,
|
||||
//left: 8.0 * x,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: statusNotifier,
|
||||
builder: (context, state, child) {
|
||||
return GestureDetector(
|
||||
onVerticalDragEnd: _onVerticalDragEnd,
|
||||
onVerticalDragUpdate: _onVerticalDragUpdate,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: SizeTransition(
|
||||
sizeFactor: sizeAnimation,
|
||||
axisAlignment: -1.0,
|
||||
axis: Axis.vertical,
|
||||
child: SizedBox(
|
||||
height: screenHeight,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: statusNotifier,
|
||||
builder: (context, state, _) => Stack(
|
||||
children: [
|
||||
if (state != AnimationStatus.dismissed)
|
||||
Positioned.fill(
|
||||
key: const Key('player_screen'),
|
||||
child: widget.expandedPanel,
|
||||
),
|
||||
if (state != AnimationStatus.completed)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
key: const Key('player_bar'),
|
||||
child: FadeTransition(
|
||||
opacity: Tween(begin: 1.0, end: 0.0)
|
||||
.animate(dragController),
|
||||
child: SizedBox(
|
||||
height: widget.bottomPanelHeight,
|
||||
child: widget.bottomPanel),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onVerticalDragUpdate(DragUpdateDetails details) {
|
||||
dragController.value -=
|
||||
details.delta.dy / MediaQuery.of(context).size.height;
|
||||
}
|
||||
|
||||
void _onVerticalDragEnd(DragEndDetails details) {
|
||||
// snap widget to size
|
||||
// this should be also handled by drag velocity and not only with bare size.
|
||||
|
||||
const double minFlingVelocity = 365.0;
|
||||
|
||||
if (details.velocity.pixelsPerSecond.dy.abs() > minFlingVelocity) {
|
||||
dragController.fling(
|
||||
velocity: -details.velocity.pixelsPerSecond.dy /
|
||||
MediaQuery.of(context).size.height);
|
||||
return;
|
||||
}
|
||||
|
||||
dragController.fling(velocity: dragController.value > 0.5 ? 1.0 : -1.0);
|
||||
}
|
||||
}
|
||||
|
||||
class PlayerBar extends StatelessWidget {
|
||||
final VoidCallback? onTap;
|
||||
final bool shouldHaveHero;
|
||||
|
|
|
@ -11,12 +11,13 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|||
import 'package:freezer/api/cache.dart';
|
||||
import 'package:freezer/api/deezer.dart';
|
||||
import 'package:freezer/api/definitions.dart';
|
||||
import 'package:freezer/api/player.dart';
|
||||
import 'package:freezer/api/player/audio_handler.dart';
|
||||
import 'package:freezer/main.dart';
|
||||
import 'package:freezer/page_routes/fade.dart';
|
||||
import 'package:freezer/settings.dart';
|
||||
import 'package:freezer/translations.i18n.dart';
|
||||
import 'package:freezer/ui/cached_image.dart';
|
||||
import 'package:freezer/ui/fancy_scaffold.dart';
|
||||
import 'package:freezer/ui/lyrics_screen.dart';
|
||||
import 'package:freezer/ui/menu.dart';
|
||||
import 'package:freezer/ui/player_bar.dart';
|
||||
|
@ -1151,7 +1152,8 @@ class BottomBarControls extends StatelessWidget {
|
|||
),
|
||||
iconSize: size * 0.85,
|
||||
onPressed: () async {
|
||||
await deezerAPI.dislikeTrack(audioHandler.mediaItem.value!.id);
|
||||
unawaited(
|
||||
deezerAPI.dislikeTrack(audioHandler.mediaItem.value!.id));
|
||||
if (playerHelper.queueIndex <
|
||||
audioHandler.queue.value.length - 1) {
|
||||
audioHandler.skipToNext();
|
||||
|
|
|
@ -4,7 +4,7 @@ import 'package:audio_service/audio_service.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:freezer/api/definitions.dart';
|
||||
import 'package:freezer/api/player.dart';
|
||||
import 'package:freezer/api/player/audio_handler.dart';
|
||||
import 'package:freezer/translations.i18n.dart';
|
||||
import 'package:freezer/ui/menu.dart';
|
||||
import 'package:freezer/ui/tiles.dart';
|
||||
|
|
|
@ -8,7 +8,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|||
import 'package:fluttericon/typicons_icons.dart';
|
||||
import 'package:freezer/api/cache.dart';
|
||||
import 'package:freezer/api/download.dart';
|
||||
import 'package:freezer/api/player.dart';
|
||||
import 'package:freezer/api/player/audio_handler.dart';
|
||||
import 'package:freezer/notifiers/list_notifier.dart';
|
||||
import 'package:freezer/ui/details_screens.dart';
|
||||
import 'package:freezer/ui/elements.dart';
|
||||
|
@ -283,7 +283,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
List<Track?> queue = cache.searchHistory
|
||||
.where((h) =>
|
||||
h.type == SearchHistoryItemType.track)
|
||||
.map<Track>((t) => Track.fromJson(t.data))
|
||||
.map<Track>((t) => t.data)
|
||||
.toList();
|
||||
playerHelper.playFromTrackList(
|
||||
queue,
|
||||
|
|
|
@ -19,7 +19,7 @@ import 'package:url_launcher/url_launcher.dart';
|
|||
import 'package:freezer/api/cache.dart';
|
||||
import 'package:freezer/api/deezer.dart';
|
||||
import 'package:freezer/api/download.dart';
|
||||
import 'package:freezer/api/player.dart';
|
||||
import 'package:freezer/api/player/audio_handler.dart';
|
||||
import 'package:freezer/ui/downloads_screen.dart';
|
||||
import 'package:freezer/ui/elements.dart';
|
||||
import 'package:freezer/ui/home_screen.dart';
|
||||
|
@ -579,19 +579,29 @@ class _QualitySettingsState extends State<QualitySettings> {
|
|||
appBar: AppBar(title: Text('Quality'.i18n)),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
title: Text('Mobile streaming'.i18n),
|
||||
leading:
|
||||
const LeadingIcon(Icons.network_cell, color: Color(0xff384697)),
|
||||
),
|
||||
const QualityPicker('mobile'),
|
||||
const FreezerDivider(),
|
||||
ListTile(
|
||||
title: Text('Wifi streaming'.i18n),
|
||||
leading:
|
||||
const LeadingIcon(Icons.network_wifi, color: Color(0xff0880b5)),
|
||||
),
|
||||
const QualityPicker('wifi'),
|
||||
...(playerHelper.isConnectivityPluginAvailable
|
||||
? [
|
||||
ListTile(
|
||||
title: Text('Mobile streaming'.i18n),
|
||||
leading: const LeadingIcon(Icons.network_cell,
|
||||
color: Color(0xff384697)),
|
||||
),
|
||||
const QualityPicker('mobile'),
|
||||
const FreezerDivider(),
|
||||
ListTile(
|
||||
title: Text('Wifi streaming'.i18n),
|
||||
leading: const LeadingIcon(Icons.network_wifi,
|
||||
color: Color(0xff0880b5)),
|
||||
),
|
||||
const QualityPicker('wifi'),
|
||||
]
|
||||
: [
|
||||
ListTile(
|
||||
title: Text('Streaming'.i18n),
|
||||
leading: const LeadingIcon(Icons.cloud,
|
||||
color: Color(0xff384697))),
|
||||
const QualityPicker('mobile_wifi'),
|
||||
]),
|
||||
const FreezerDivider(),
|
||||
ListTile(
|
||||
title: Text('Offline'.i18n),
|
||||
|
@ -622,6 +632,8 @@ class QualityPicker extends StatefulWidget {
|
|||
|
||||
class _QualityPickerState extends State<QualityPicker> {
|
||||
late AudioQuality _quality;
|
||||
bool flacDisabled = !cache.canStreamLossless;
|
||||
bool hqDisabled = !cache.canStreamHQ;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
@ -632,6 +644,7 @@ class _QualityPickerState extends State<QualityPicker> {
|
|||
//Get current quality
|
||||
void _getQuality() {
|
||||
switch (widget.field) {
|
||||
case 'mobile_wifi':
|
||||
case 'mobile':
|
||||
_quality = settings.mobileQuality;
|
||||
break;
|
||||
|
@ -655,6 +668,10 @@ class _QualityPickerState extends State<QualityPicker> {
|
|||
_quality = q;
|
||||
});
|
||||
switch (widget.field) {
|
||||
case 'mobile_wifi':
|
||||
settings.mobileQuality = settings.wifiQuality = _quality;
|
||||
settings.updateAudioServiceQuality();
|
||||
break;
|
||||
case 'mobile':
|
||||
settings.mobileQuality = _quality;
|
||||
settings.updateAudioServiceQuality();
|
||||
|
@ -671,7 +688,6 @@ class _QualityPickerState extends State<QualityPicker> {
|
|||
break;
|
||||
}
|
||||
await settings.save();
|
||||
await settings.updateAudioServiceQuality();
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -682,26 +698,27 @@ class _QualityPickerState extends State<QualityPicker> {
|
|||
title: const Text('MP3 128kbps'),
|
||||
groupValue: _quality,
|
||||
value: AudioQuality.MP3_128,
|
||||
onChanged: (q) => _updateQuality(q),
|
||||
onChanged: (AudioQuality? q) => _updateQuality(q),
|
||||
),
|
||||
RadioListTile(
|
||||
title: const Text('MP3 320kbps'),
|
||||
groupValue: _quality,
|
||||
value: AudioQuality.MP3_320,
|
||||
onChanged: (q) => _updateQuality(q),
|
||||
onChanged: hqDisabled ? null : (AudioQuality? q) => _updateQuality(q),
|
||||
),
|
||||
RadioListTile(
|
||||
title: const Text('FLAC'),
|
||||
groupValue: _quality,
|
||||
value: AudioQuality.FLAC,
|
||||
onChanged: (q) => _updateQuality(q),
|
||||
onChanged:
|
||||
flacDisabled ? null : (AudioQuality? q) => _updateQuality(q),
|
||||
),
|
||||
if (widget.field == 'download')
|
||||
RadioListTile(
|
||||
title: Text('Ask before downloading'.i18n),
|
||||
groupValue: _quality,
|
||||
value: AudioQuality.ASK,
|
||||
onChanged: (q) => _updateQuality(q),
|
||||
onChanged: (AudioQuality? q) => _updateQuality(q),
|
||||
)
|
||||
],
|
||||
);
|
||||
|
|
|
@ -4,7 +4,7 @@ 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/api/player/audio_handler.dart';
|
||||
import 'package:freezer/translations.i18n.dart';
|
||||
|
||||
import '../api/definitions.dart';
|
||||
|
|
Loading…
Reference in New Issue