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:
Pato05 2023-10-18 17:08:05 +02:00
parent b629998416
commit f126ffef46
No known key found for this signature in database
GPG Key ID: ED4C6F9C3D574FB6
28 changed files with 1228 additions and 3079 deletions

2
.gitignore vendored
View File

@ -51,7 +51,7 @@ android/app/.cxx
.pub/
/build/
.gradle/
*.g.dart
# Web related
lib/generated_plugin_registrant.dart

View File

@ -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;

View File

@ -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,
};

104
lib/api/cache_provider.dart Normal file
View File

@ -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();
}

View File

@ -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']);
}

View File

@ -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': '*',

View File

@ -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

49
lib/api/paths.dart Normal file
View File

@ -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;
}
}

View File

@ -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();
}

View File

@ -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');
// }
}

View File

@ -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,

View File

@ -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,
});

View File

@ -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]!;
}
}

View File

@ -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(),
};

View File

@ -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 {

View File

@ -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()
],
),
));
));
}
}

195
lib/ui/fancy_scaffold.dart Normal file
View File

@ -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);
}
}

View File

@ -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';

View File

@ -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';

View File

@ -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';

View File

@ -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));
}

View File

@ -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;

View File

@ -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();

View File

@ -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';

View File

@ -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,

View File

@ -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),
)
],
);

View File

@ -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';