pre-new design

This commit is contained in:
pato05 2021-11-01 17:41:25 +01:00
parent 1ce60e70de
commit c792daea19
No known key found for this signature in database
GPG key ID: C7C6C9EDFCBB6681
28 changed files with 1772 additions and 1220 deletions

View file

@ -19,7 +19,7 @@ class DeezerAPI {
String? favoritesPlaylistId;
String? sid;
Future? _authorizing;
Future<bool>? _authorizing;
//Get headers
Map<String, String> get headers => {
@ -76,12 +76,7 @@ class DeezerAPI {
}
//Wrapper so it can be globally awaited
Future? authorize() async {
if (_authorizing == null) {
this._authorizing = this.rawAuthorize();
}
return _authorizing;
}
Future<bool> authorize() async => this._authorizing ??= this.rawAuthorize();
//Login with email
static Future<String?> getArlByEmail(String? email, String password) async {

View file

@ -1,10 +1,16 @@
import 'dart:math';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:audio_service/audio_service.dart';
import 'package:freezer/api/cache.dart';
import 'package:freezer/page_routes/blur_slide.dart';
import 'package:freezer/page_routes/fade.dart';
import 'package:freezer/settings.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:intl/intl.dart';
import 'package:just_audio/just_audio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import 'package:freezer/translations.i18n.dart';
@ -760,8 +766,8 @@ class HomePageSection {
}
class HomePageItem {
HomePageItemType? type;
dynamic value;
final HomePageItemType? type;
final value;
HomePageItem({this.type, this.value});
@ -831,7 +837,7 @@ class HomePageItem {
}
Map<String, dynamic> toJson() {
String type = this.type.toString().split('.').last;
String type = describeEnum(this.type!);
return {'type': type, 'value': value.toJson()};
}
}
@ -1077,17 +1083,95 @@ Map<String, dynamic> mediaItemToJson(MediaItem mi) => {
'displayDescription': mi.displayDescription,
};
MediaItem mediaItemFromJson(Map<String, dynamic> json) => MediaItem(
id: json['id'],
title: json['title'],
id: json['id'] as String,
title: json['title'] as String,
artUri: json['artUri'] == null ? null : Uri.parse(json['artUri']),
playable: json['playable'] as bool,
playable: json['playable'] as bool?,
duration: json['duration'] == null
? null
: Duration(milliseconds: json['duration'] as int),
extras: json['extras'] as Map<String, dynamic>,
album: json['album'],
artist: json['artist'],
displayTitle: json['displayTitle'],
displaySubtitle: json['displaySubtitle'],
displayDescription: json['displayDescription'],
extras: json['extras'] as Map<String, dynamic>?,
album: json['album'] as String?,
artist: json['artist'] as String?,
displayTitle: json['displayTitle'] as String?,
displaySubtitle: json['displaySubtitle'] as String?,
displayDescription: json['displayDescription'] as String?,
);
/// Will generate a new darkened color by [percent], and leaves the opacity untouched
///
/// [percent] is a double which value is from 0 to 1, the closer to one, the darker the color is
Color darken(Color color, {double percent = 0.25}) =>
Color.lerp(color, Colors.black, percent)!;
extension LastItem<T> on List<T> {
T get lastItem => this[length - 1];
}
extension ToLoopMode on AudioServiceRepeatMode {
LoopMode toLoopMode() {
switch (this) {
case AudioServiceRepeatMode.none:
return LoopMode.off;
case AudioServiceRepeatMode.one:
return LoopMode.one;
case AudioServiceRepeatMode.group:
case AudioServiceRepeatMode.all:
return LoopMode.all;
}
}
}
// extension ToAudioServiceRepeatMode on LoopMode {
// AudioServiceRepeatMode toAudioServiceRepeatMode() {
// switch (this) {
// case LoopMode.off:
// return AudioServiceRepeatMode.none;
// case LoopMode.one:
// return AudioServiceRepeatMode.one;
// case LoopMode.all:
// return AudioServiceRepeatMode.all;
// }
// }
// }
extension PushRoute on NavigatorState {
Future<T?> pushRoute<T extends Object?>({required WidgetBuilder builder}) {
final PageRoute<T> route;
switch (settings.navigatorRouteType) {
case NavigatorRouteType.blur_slide:
route = BlurSlidePageRoute<T>(builder: builder);
break;
case NavigatorRouteType.material:
route = MaterialPageRoute<T>(builder: builder);
break;
case NavigatorRouteType.cupertino:
route = CupertinoPageRoute<T>(builder: builder);
break;
case NavigatorRouteType.fade:
route = FadePageRoute<T>(builder: builder);
break;
case NavigatorRouteType.fade_blur:
route = FadePageRoute<T>(builder: builder, blur: true);
break;
}
return push(route);
}
}
enum NavigatorRouteType {
/// Slide from the bottom, with a backdrop filter on the previous screen
blur_slide,
/// Fade
fade,
/// Fade with blur
fade_blur,
/// Standard material route look
material,
/// Standard cupertino route look
cupertino,
}

View file

@ -25,7 +25,7 @@ class DownloadManager {
static EventChannel eventChannel =
const EventChannel('f.f.freezer/downloads');
bool? running = false;
bool running = false;
int? queueSize = 0;
StreamController serviceEvents = StreamController.broadcast();
@ -92,8 +92,7 @@ class DownloadManager {
//Get all downloads from db
Future<List<Download>> getDownloads() async {
List raw = await (platform.invokeMethod('getDownloads')
as FutureOr<List<dynamic>>);
List raw = await platform.invokeMethod('getDownloads');
return raw.map((d) => Download.fromJson(d)).toList();
}
@ -535,14 +534,14 @@ class DownloadManager {
//Download path
path = settings.downloadPath;
if (settings.playlistFolder! && playlistName != null)
if (settings.playlistFolder && playlistName != null)
path = p.join(path!, sanitize(playlistName));
if (settings.artistFolder!) path = p.join(path!, '%albumArtist%');
if (settings.artistFolder) path = p.join(path!, '%albumArtist%');
//Album folder / with disk number
if (settings.albumFolder!) {
if (settings.albumDiscFolder!) {
if (settings.albumFolder) {
if (settings.albumDiscFolder) {
path = p.join(path!,
'%album%' + ' - Disk ' + (track!.diskNumber ?? 1).toString());
} else {

View file

@ -28,21 +28,51 @@ class PlayerHelper {
late StreamSubscription _mediaItemSubscription;
late StreamSubscription _playbackStateStreamSubscription;
QueueSource? queueSource;
LoopMode repeatType = LoopMode.off;
AudioServiceRepeatMode repeatType = AudioServiceRepeatMode.none;
Timer? _timer;
int? audioSession;
int? _prevAudioSession;
bool equalizerOpen = false;
bool _shuffleEnabled = false;
int _queueIndex = 0;
//Visualizer
StreamController _visualizerController = StreamController.broadcast();
Stream get visualizerStream => _visualizerController.stream;
//Find queue index by id
int get queueIndex => audioHandler.queue.value
.indexWhere((mi) => mi.id == audioHandler.mediaItem.value?.id);
/// 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);
Future start() async {
int getQueueIndex() =>
audioHandler.playbackState.value.queueIndex ?? getQueueIndexFromId();
int get queueIndex => _queueIndex;
Future<void> initAudioHandler() async {
// initialize our audiohandler instance
audioHandler = await AudioService.init(
builder: () => AudioPlayerTask(),
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 {
audioHandler.customAction(
'start', {'ignoreInterruptions': settings.ignoreInterruptions});
//Subscribe to custom events
_customEventSubscription = audioHandler.customEvent.listen((event) async {
if (!(event is Map)) return;
@ -50,13 +80,14 @@ class PlayerHelper {
case 'onLoad':
//After audio_service is loaded, load queue, set quality
await settings.updateAudioServiceQuality();
await audioHandler.customAction('load', {});
await audioHandler.customAction('load');
await authorizeLastFM();
break;
case 'onRestore':
//Load queueSource from isolate
this.queueSource = QueueSource.fromJson(event['queueSource']);
repeatType = LoopMode.values[event['loopMode']];
repeatType = AudioServiceRepeatMode.values[event['loopMode']];
_queueIndex = getQueueIndex();
break;
case 'queueEnd':
//If last song is played, load more queue
@ -74,7 +105,7 @@ class PlayerHelper {
await androidAuto.playItem(event['id']);
break;
case 'audioSession':
if (!settings.enableEqualizer!) break;
if (!settings.enableEqualizer) break;
//Save
_prevAudioSession = audioSession;
audioSession = event['id'];
@ -98,16 +129,21 @@ class PlayerHelper {
break;
}
});
_mediaItemSubscription = audioHandler.mediaItem.listen((event) {
if (event == null) return;
//Load more flow if index-1 song
if (queueIndex == audioHandler.queue.value.length - 1) onQueueEnd();
_mediaItemSubscription = audioHandler.mediaItem.listen((mediaItem) async {
if (mediaItem == null) return;
final queue = audioHandler.queue.value;
final nextIndex = (_queueIndex + 1) % queue.length;
print('animating $nextIndex');
_queueIndex = getQueueIndex();
//Load more flow if last song (not using .last since it iterates through previous elements first)
if (mediaItem.id == queue[queue.length - 1].id) await onQueueEnd();
//Save queue
audioHandler.customAction('saveQueue', {});
await audioHandler.customAction('saveQueue', {});
//Add to history
if (cache.history.length > 0 && cache.history.last.id == event.id) return;
cache.history.add(Track.fromMediaItem(event));
if (cache.history.length > 0 && cache.history.last.id == mediaItem.id)
return;
cache.history.add(Track.fromMediaItem(mediaItem));
cache.save();
});
@ -141,26 +177,25 @@ class PlayerHelper {
});
}
Future toggleShuffle() async {
await audioHandler.customAction('shuffle');
Future<bool> toggleShuffle() async {
await audioHandler.setShuffleMode((_shuffleEnabled = !_shuffleEnabled)
? AudioServiceShuffleMode.all
: AudioServiceShuffleMode.none);
return _shuffleEnabled;
}
bool get shuffleEnabled => _shuffleEnabled;
//Repeat toggle
Future changeRepeat() async {
//Change to next repeat type
switch (repeatType) {
case LoopMode.one:
repeatType = LoopMode.off;
break;
case LoopMode.all:
repeatType = LoopMode.one;
break;
default:
repeatType = LoopMode.all;
break;
}
repeatType = repeatType == AudioServiceRepeatMode.all
? AudioServiceRepeatMode.none
: repeatType == AudioServiceRepeatMode.none
? AudioServiceRepeatMode.one
: AudioServiceRepeatMode.all;
//Set repeat type
await audioHandler.customAction('repeatType', {'type': repeatType.index});
await audioHandler.setRepeatMode(repeatType);
}
//Executed before exit
@ -183,11 +218,11 @@ class PlayerHelper {
//Called when queue ends to load more tracks
Future onQueueEnd() async {
//Flow
if (queueSource == null) return;
List<Track>? tracks = [];
List<Track>? tracks;
switch (queueSource!.source) {
//Flow
case 'flow':
tracks = await deezerAPI.flow();
break;
@ -209,13 +244,13 @@ class PlayerHelper {
tracks?.removeWhere((track) => queueIds.contains(track.id));
break;
default:
// print(queueSource.toJson());
break;
return;
// print(queueSource.toJson());
}
if (tracks == null) {
// try again i guess?
return await onQueueEnd();
throw Exception(
'Failed to fetch more queue songs (queueSource: ${queueSource!.toJson()})');
}
List<MediaItem> mi = tracks.map<MediaItem>((t) => t.toMediaItem()).toList();
@ -231,8 +266,7 @@ class PlayerHelper {
//Play mix by track
Future playMix(String trackId, String trackTitle) async {
List<Track> tracks =
await (deezerAPI.playMix(trackId) as FutureOr<List<Track>>);
List<Track> tracks = (await deezerAPI.playMix(trackId))!;
playFromTrackList(
tracks,
tracks[0].id,
@ -337,9 +371,7 @@ class AudioPlayerTask extends BaseAudioHandler {
late AudioPlayer _player;
//Queue
List<MediaItem>? _queue = <MediaItem>[];
List<MediaItem>? _originalQueue;
bool _shuffle = false;
int _queueIndex = 0;
bool _isInitialized = false;
late ConcatenatingAudioSource _audioSource;
@ -356,7 +388,7 @@ class AudioPlayerTask extends BaseAudioHandler {
int? wifiQuality;
QueueSource? queueSource;
Duration? _lastPosition;
LoopMode _loopMode = LoopMode.off;
AudioServiceRepeatMode _repeatMode = AudioServiceRepeatMode.none;
Completer<List<MediaItem>>? _androidAutoCallback;
Scrobblenaut? _scrobblenaut;
@ -364,11 +396,7 @@ class AudioPlayerTask extends BaseAudioHandler {
// Last logged track id
String? _loggedTrackId;
MediaItem get currentMediaItem => _queue![_queueIndex];
AudioPlayerTask() {
onStart({}); // workaround i guess?
}
MediaItem get currentMediaItem => queue.value[_queueIndex];
Future onStart(Map<String, dynamic>? params) async {
final session = await AudioSession.instance;
@ -391,10 +419,10 @@ class AudioPlayerTask extends BaseAudioHandler {
//Update state on all clients on change
_eventSub = _player.playbackEventStream.listen((event) {
//Quality string
if (_queueIndex != -1 && _queueIndex < _queue!.length) {
if (_queueIndex != -1 && _queueIndex < queue.value.length) {
Map extras = currentMediaItem.extras!;
extras['qualityString'] = '';
_queue![_queueIndex] =
queue.value[_queueIndex] =
currentMediaItem.copyWith(extras: extras as Map<String, dynamic>?);
}
//Update
@ -404,7 +432,7 @@ class AudioPlayerTask extends BaseAudioHandler {
switch (state) {
case ProcessingState.completed:
//Player ended, get more songs
if (_queueIndex == _queue!.length - 1)
if (_queueIndex == queue.value.length - 1)
customEvent.add({
'action': 'queueEnd',
'queueSource': (queueSource ?? QueueSource()).toJson()
@ -421,7 +449,7 @@ class AudioPlayerTask extends BaseAudioHandler {
});
//Load queue
queue.add(_queue!);
// queue.add(_queue);
customEvent.add({'action': 'onLoad'});
}
@ -477,25 +505,25 @@ class AudioPlayerTask extends BaseAudioHandler {
//Remove item from queue
@override
Future<void> removeQueueItem(MediaItem mediaItem) async {
int index = _queue!.indexWhere((m) => m.id == mediaItem.id);
int index = queue.value.indexWhere((m) => m.id == mediaItem.id);
removeQueueItemAt(index);
}
@override
Future<void> removeQueueItemAt(int index) async {
_queue!.removeAt(index);
if (index <= _queueIndex) {
_queueIndex--;
}
await _audioSource.removeAt(index);
queue.add(_queue!);
queue.add(queue.value..removeAt(index));
}
@override
Future<void> skipToNext() async {
_lastPosition = null;
if (_queueIndex == _queue!.length - 1) return;
if (_queueIndex == queue.value.length - 1) return;
//Update buffering state
_queueIndex++;
await _player.seekToNext();
@ -549,46 +577,42 @@ class AudioPlayerTask extends BaseAudioHandler {
//Update state on all clients
void _broadcastState() {
playbackState.add(PlaybackState(
controls: [
if (_queueIndex != 0) MediaControl.skipToPrevious,
_player.playing ? MediaControl.pause : MediaControl.play,
if (_queueIndex != _queue!.length - 1) MediaControl.skipToNext,
//Stop
MediaControl(
androidIcon: 'drawable/ic_action_stop',
label: 'stop',
action: MediaAction.stop),
],
systemActions: const {
MediaAction.seek,
MediaAction.seekForward,
MediaAction.seekBackward,
MediaAction.stop
},
processingState: _getProcessingState(),
playing: _player.playing,
updatePosition: _player.position,
bufferedPosition: _player.bufferedPosition,
speed: _player.speed));
controls: [
/*if (_queueIndex != 0)*/ MediaControl.skipToPrevious,
_player.playing ? MediaControl.pause : MediaControl.play,
/*if (_queueIndex != _queue!.length - 1)*/ MediaControl.skipToNext,
//Stop
// MediaControl(
// androidIcon: 'drawable/ic_action_stop',
// label: 'stop',
// action: MediaAction.stop),
// i mean, the user can just swipe the notification away to stop
],
systemActions: const {
MediaAction.seek,
MediaAction.seekForward,
MediaAction.seekBackward,
MediaAction.stop
},
processingState: _getProcessingState(),
playing: _player.playing,
updatePosition: _player.position,
bufferedPosition: _player.bufferedPosition,
speed: _player.speed,
queueIndex: _queueIndex,
));
}
//just_audio state -> audio_service state. If skipping, use _skipState
AudioProcessingState _getProcessingState() {
//SRC: audio_service example
switch (_player.processingState) {
case ProcessingState.idle:
return AudioProcessingState.idle;
case ProcessingState.loading:
return AudioProcessingState.loading;
case ProcessingState.buffering:
return AudioProcessingState.buffering;
case ProcessingState.ready:
return AudioProcessingState.ready;
case ProcessingState.completed:
return AudioProcessingState.completed;
default:
throw Exception("Invalid state: ${_player.processingState}");
}
return const <ProcessingState, AudioProcessingState>{
ProcessingState.idle: AudioProcessingState.idle,
ProcessingState.loading: AudioProcessingState.loading,
ProcessingState.buffering: AudioProcessingState.buffering,
ProcessingState.ready: AudioProcessingState.ready,
ProcessingState.completed: AudioProcessingState.completed
}[_player.processingState] ??
AudioProcessingState.idle;
}
//Replace current queue
@ -596,19 +620,16 @@ class AudioPlayerTask extends BaseAudioHandler {
Future updateQueue(List<MediaItem> q) async {
_lastPosition = null;
//just_audio
_shuffle = false;
_originalQueue = null;
_player.stop();
if (_isInitialized) _audioSource.clear();
//Filter duplicate IDs
List<MediaItem> newQueue = q.toSet().toList();
_queue = newQueue;
//Load
await _loadQueue();
// broadcast to ui
queue.add(newQueue);
//Load
await _loadQueue();
//await _player.seek(Duration.zero, index: 0);
}
@ -618,12 +639,12 @@ class AudioPlayerTask extends BaseAudioHandler {
int? qi = _queueIndex;
List<AudioSource> sources = [];
for (int i = 0; i < _queue!.length; i++) {
AudioSource s = await _mediaItemToAudioSource(_queue![i]);
for (int i = 0; i < queue.value.length; i++) {
AudioSource s = await _mediaItemToAudioSource(queue.value[i]);
sources.add(s);
}
_audioSource = ConcatenatingAudioSource(children: sources);
//Load in just_audio
try {
await _player.setAudioSource(_audioSource,
@ -678,6 +699,9 @@ class AudioPlayerTask extends BaseAudioHandler {
@override
Future customAction(String name, [Map<String, dynamic>? args]) async {
switch (name) {
case 'start':
onStart(args);
break;
case 'updateQuality':
//Pass wifi & mobile quality by custom action
//Isolate can't access globals
@ -689,10 +713,10 @@ class AudioPlayerTask extends BaseAudioHandler {
this.queueSource = QueueSource.fromJson(args!);
break;
//Looping
case 'repeatType':
_loopMode = LoopMode.values[args!['type']];
_player.setLoopMode(_loopMode);
break;
// case 'repeatType':
// _loopMode = LoopMode.values[args!['type']];
// _player.setLoopMode(_loopMode);
// break;
//Save queue
case 'saveQueue':
await this._saveQueue();
@ -701,29 +725,6 @@ class AudioPlayerTask extends BaseAudioHandler {
case 'load':
await this._loadQueueFile();
break;
case 'shuffle':
/// TODO: maybe use [_player.setShuffleModeEnabled] instead?
// why is this even a thing?
// String originalId = mediaItem.id;
if (!_shuffle) {
_shuffle = true;
_originalQueue = List.from(_queue!);
_queue!.shuffle();
} else {
_shuffle = false;
_queue = _originalQueue;
_originalQueue = null;
}
//Broken
// _queueIndex = _queue.indexWhere((mi) => mi.id == originalId);
_queueIndex = 0;
queue.add(_queue!);
// AudioServiceBackground.setMediaItem(mediaItem);
await _player.stop();
await _loadQueue();
await _player.play();
break;
//Android audio callback
case 'screenAndroidAuto':
@ -738,14 +739,11 @@ class AudioPlayerTask extends BaseAudioHandler {
final oldIndex = args!['oldIndex']! as int;
final newIndex = args['newIndex']! as int;
await _audioSource.move(oldIndex, newIndex);
//Switch in queue
_queue!.reorder(oldIndex, newIndex);
//Update UI
queue.add(_queue!);
queue.add(queue.value..reorder(oldIndex, newIndex));
_broadcastState();
break;
//Set index without affecting playback for loading
case 'setIndex': // i really don't get what this is for
case 'setIndex': // editor's note: i really don't get what this is for
this._queueIndex = args!['index'];
break;
//Start visualizer
@ -824,7 +822,7 @@ class AudioPlayerTask extends BaseAudioHandler {
//Export queue to JSON
Future<void> _saveQueue() async {
if (_queueIndex == 0 && _queue!.length == 0) return;
if (_queueIndex == 0 && queue.value.length == 0) return;
String path = await _getQueuePath();
File f = File(path);
@ -834,10 +832,10 @@ class AudioPlayerTask extends BaseAudioHandler {
}
Map data = {
'index': _queueIndex,
'queue': _queue!.map<Map<String, dynamic>>(mediaItemToJson).toList(),
'queue': queue.value.map<Map<String, dynamic>>(mediaItemToJson).toList(),
'position': _player.position.inMilliseconds,
'queueSource': (queueSource ?? QueueSource()).toJson(),
'loopMode': LoopMode.values.indexOf(_loopMode)
'loopMode': _repeatMode.index,
};
await f.writeAsString(jsonEncode(data));
}
@ -847,25 +845,29 @@ class AudioPlayerTask extends BaseAudioHandler {
File f = File(await _getQueuePath());
if (await f.exists()) {
Map<String, dynamic> json = jsonDecode(await f.readAsString());
this._queue =
(json['queue'] ?? []).map<MediaItem>(mediaItemFromJson).toList();
this._queueIndex = json['index'] ?? 0;
this._lastPosition = Duration(milliseconds: json['position'] ?? 0);
this.queueSource = QueueSource.fromJson(json['queueSource'] ?? {});
this._loopMode = LoopMode.values[(json['loopMode'] ?? 0)];
List<MediaItem>? _queue = (json['queue'] as List?)
?.cast<Map>()
.map<MediaItem>(
(json) => mediaItemFromJson(json.cast<String, dynamic>()))
.toList();
_queueIndex = json['index'] ?? 0;
_lastPosition = Duration(milliseconds: json['position'] ?? 0);
queueSource = QueueSource.fromJson(json['queueSource'] ?? {});
_repeatMode = AudioServiceRepeatMode.values[(json['loopMode'] ?? 0)];
//Restore queue
if (_queue != null) {
queue.add(_queue!);
queue.add(_queue);
await _loadQueue();
mediaItem.add(currentMediaItem);
}
//Send restored queue source to ui
customEvent.add({
'action': 'onRestore',
'queueSource': (queueSource ?? QueueSource()).toJson(),
'loopMode': _repeatMode.index
});
}
//Send restored queue source to ui
customEvent.add({
'action': 'onRestore',
'queueSource': (queueSource ?? QueueSource()).toJson(),
'loopMode': LoopMode.values.indexOf(_loopMode)
});
return true;
}
@ -874,8 +876,7 @@ class AudioPlayerTask extends BaseAudioHandler {
//-1 == play next
if (index == -1) index = _queueIndex + 1;
_queue!.insert(index, mi);
queue.add(_queue!);
queue.add(queue.value..insert(index, mi));
AudioSource? _newSource = await _mediaItemToAudioSource(mi);
await _audioSource.insert(index, _newSource);
@ -886,10 +887,9 @@ class AudioPlayerTask extends BaseAudioHandler {
@override
Future<void> addQueueItem(MediaItem mediaItem,
{bool shouldSaveQueue = true}) async {
if (_queue!.indexWhere((m) => m.id == mediaItem.id) != -1) return;
if (queue.value.indexWhere((m) => m.id == mediaItem.id) != -1) return;
_queue!.add(mediaItem);
queue.add(_queue!);
queue.add(queue.value..add(mediaItem));
AudioSource _newSource = await _mediaItemToAudioSource(mediaItem);
await _audioSource.add(_newSource);
if (shouldSaveQueue) _saveQueue();
@ -917,25 +917,40 @@ class AudioPlayerTask extends BaseAudioHandler {
//Does the same thing
await this
.skipToQueueItem(_queue!.indexWhere((item) => item.id == mediaId));
.skipToQueueItem(queue.value.indexWhere((item) => item.id == mediaId));
}
@override
Future<MediaItem?> getMediaItem(String mediaId) async =>
_queue!.firstWhereOrNull((mediaItem) => mediaItem.id == mediaId);
queue.value.firstWhereOrNull((mediaItem) => mediaItem.id == mediaId);
@override
Future<void> playMediaItem(MediaItem mediaItem) =>
playFromMediaId(mediaItem.id);
// TODO: implement shuffle and repeat
@override
Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) =>
super.setRepeatMode(repeatMode);
_player.setLoopMode(repeatMode.toLoopMode());
@override
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) =>
super.setShuffleMode(shuffleMode);
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
switch (shuffleMode) {
case AudioServiceShuffleMode.none:
queue.add(_originalQueue!);
_originalQueue = null;
break;
case AudioServiceShuffleMode.group:
case AudioServiceShuffleMode.all:
_originalQueue = List.from(queue.value, growable: false);
queue.add(queue.value..shuffle());
break;
}
_queueIndex = 0;
await _player.stop();
await _loadQueue();
await _player.play();
}
}
//Seeker from audio_service example (why reinvent the wheel?)

View file

@ -41,21 +41,7 @@ void main() async {
//Do on BG
playerHelper.authorizeLastFM();
// initialize our audiohandler instance
audioHandler = await AudioService.init<AudioPlayerTask>(
builder: () => AudioPlayerTask(),
config: AudioServiceConfig(
notificationColor: settings.primaryColor,
androidStopForegroundOnPause: false,
androidNotificationOngoing: false,
androidNotificationClickStartsActivity: true,
androidNotificationChannelDescription: 'Freezer',
androidNotificationChannelName: 'Freezer',
androidNotificationIcon: 'drawable/ic_logo',
preloadArtwork: false,
),
);
await playerHelper.initAudioHandler();
runApp(FreezerApp());
}
@ -66,15 +52,33 @@ class FreezerApp extends StatefulWidget {
}
class _FreezerAppState extends State<FreezerApp> {
late StreamSubscription _playbackStateSub;
@override
void initState() {
_initStateAsync();
//Make update theme global
updateTheme = _updateTheme;
super.initState();
}
Future<void> _initStateAsync() async {
_playbackStateChanged(audioHandler.playbackState.value);
_playbackStateSub =
audioHandler.playbackState.listen(_playbackStateChanged);
}
Future<void> _playbackStateChanged(PlaybackState playbackState) async {
if (playbackState.processingState == AudioProcessingState.idle ||
playbackState.processingState == AudioProcessingState.error) {
// reconnect maybe?
return;
}
}
@override
void dispose() {
_playbackStateSub.cancel();
super.dispose();
}
@ -143,7 +147,7 @@ class _LoginMainWrapperState extends State<LoginMainWrapper> {
//Load token on background
deezerAPI.arl = settings.arl;
settings.offlineMode = true;
deezerAPI.authorize()!.then((b) async {
deezerAPI.authorize().then((b) async {
if (b) setState(() => settings.offlineMode = false);
});
}

View file

@ -0,0 +1,20 @@
import 'package:flutter/widgets.dart';
abstract class BasicPageRoute<T> extends PageRoute<T> {
final Duration transitionDuration;
final bool maintainState;
BasicPageRoute({
this.transitionDuration = const Duration(milliseconds: 300),
this.maintainState = true,
});
@override
bool get barrierDismissible => false;
@override
Color? get barrierColor => null;
@override
String? get barrierLabel => null;
}

View file

@ -0,0 +1,43 @@
import 'dart:ui';
import 'package:flutter/widgets.dart';
import 'package:freezer/page_routes/basic_page_route.dart';
import 'package:freezer/ui/animated_blur.dart';
class BlurSlidePageRoute<T> extends BasicPageRoute<T> {
final WidgetBuilder builder;
final Curve animationCurve;
final _animationTween = Tween(
begin: const Offset(0.0, 1.0),
end: Offset.zero,
);
BlurSlidePageRoute({
required this.builder,
this.animationCurve = Curves.linearToEaseOut,
transitionDuration = const Duration(milliseconds: 300),
maintainState = true,
}) : super(
transitionDuration: transitionDuration,
maintainState: maintainState);
@override
Widget buildPage(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation) =>
builder(context);
@override
Widget buildTransitions(BuildContext context, Animation<double> _animation,
Animation<double> secondaryAnimation, Widget child) {
final animation =
CurvedAnimation(parent: _animation, curve: animationCurve);
return Stack(children: [
Positioned.fill(
child: AnimatedBlur(
animation: animation, multiplier: 10.0, child: const SizedBox()),
),
SlideTransition(
position: _animationTween.animate(animation), child: child),
]);
}
}

41
lib/page_routes/fade.dart Normal file
View file

@ -0,0 +1,41 @@
import 'package:flutter/cupertino.dart';
import 'package:freezer/page_routes/basic_page_route.dart';
import 'package:freezer/ui/animated_blur.dart';
class FadePageRoute<T> extends BasicPageRoute<T> {
final WidgetBuilder builder;
final bool blur;
FadePageRoute({
required this.builder,
this.blur = false,
transitionDuration = const Duration(milliseconds: 300),
maintainState = true,
}) : super(
transitionDuration: transitionDuration,
maintainState: maintainState);
@override
Widget buildPage(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation) =>
builder(context);
@override
Widget buildTransitions(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation, Widget child) {
final baseTransition = FadeTransition(
opacity: animation,
child: child,
);
if (blur) {
return Stack(children: [
Positioned.fill(
child: AnimatedBlur(
animation: animation,
multiplier: 10.0,
child: const SizedBox())),
baseTransition,
]);
}
return baseTransition;
}
}

View file

@ -1,4 +1,6 @@
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:freezer/api/definitions.dart';
import 'package:freezer/api/download.dart';
import 'package:freezer/api/player.dart';
import 'package:google_fonts/google_fonts.dart';
@ -23,9 +25,9 @@ class Settings {
//Main
@JsonKey(defaultValue: false)
bool? ignoreInterruptions;
late bool ignoreInterruptions;
@JsonKey(defaultValue: false)
bool? enableEqualizer;
late bool enableEqualizer;
//Account
String? arl;
@ -34,45 +36,45 @@ class Settings {
//Quality
@JsonKey(defaultValue: AudioQuality.MP3_320)
AudioQuality? wifiQuality;
late AudioQuality wifiQuality;
@JsonKey(defaultValue: AudioQuality.MP3_128)
AudioQuality? mobileQuality;
late AudioQuality mobileQuality;
@JsonKey(defaultValue: AudioQuality.FLAC)
AudioQuality? offlineQuality;
late AudioQuality offlineQuality;
@JsonKey(defaultValue: AudioQuality.FLAC)
AudioQuality? downloadQuality;
late AudioQuality downloadQuality;
//Download options
String? downloadPath;
@JsonKey(defaultValue: "%artist% - %title%")
String? downloadFilename;
late String downloadFilename;
@JsonKey(defaultValue: true)
bool? albumFolder;
late bool albumFolder;
@JsonKey(defaultValue: true)
bool? artistFolder;
late bool artistFolder;
@JsonKey(defaultValue: false)
bool? albumDiscFolder;
late bool albumDiscFolder;
@JsonKey(defaultValue: false)
bool? overwriteDownload;
late bool overwriteDownload;
@JsonKey(defaultValue: 2)
int? downloadThreads;
late int downloadThreads;
@JsonKey(defaultValue: false)
bool? playlistFolder;
late bool playlistFolder;
@JsonKey(defaultValue: true)
bool? downloadLyrics;
late bool downloadLyrics;
@JsonKey(defaultValue: false)
bool? trackCover;
late bool trackCover;
@JsonKey(defaultValue: true)
bool? albumCover;
late bool albumCover;
@JsonKey(defaultValue: false)
bool? nomediaFiles;
late bool nomediaFiles;
@JsonKey(defaultValue: ", ")
String? artistSeparator;
late String artistSeparator;
@JsonKey(defaultValue: "%artist% - %title%")
String? singletonFilename;
late String singletonFilename;
@JsonKey(defaultValue: 1400)
int? albumArtResolution;
late int albumArtResolution;
@JsonKey(defaultValue: [
"title",
"album",
@ -91,23 +93,29 @@ class Settings {
"contributors",
"art"
])
List<String>? tags;
late List<String> tags;
//Appearance
@JsonKey(defaultValue: Themes.Dark)
Themes? theme;
late Themes theme;
@JsonKey(defaultValue: false)
bool? useSystemTheme;
late bool useSystemTheme;
@JsonKey(defaultValue: true)
bool? colorGradientBackground;
late bool colorGradientBackground;
@JsonKey(defaultValue: false)
bool? blurPlayerBackground;
late bool blurPlayerBackground;
@JsonKey(defaultValue: "Deezer")
String? font;
late String font;
@JsonKey(defaultValue: false)
bool? lyricsVisualizer;
late bool lyricsVisualizer;
@JsonKey(defaultValue: null)
int? displayMode;
@JsonKey(defaultValue: true)
late bool enableFilledPlayButton;
@JsonKey(defaultValue: false)
late bool playerBackgroundOnLyrics;
@JsonKey(defaultValue: NavigatorRouteType.material)
late NavigatorRouteType navigatorRouteType;
//Colors
@JsonKey(toJson: _colorToJson, fromJson: _colorFromJson)
@ -147,17 +155,17 @@ class Settings {
ThemeData? get themeData {
//System theme
if (useSystemTheme!) {
if (useSystemTheme) {
if (SchedulerBinding.instance!.window.platformBrightness ==
Brightness.light) {
return _themeData[Themes.Light];
} else {
if (theme == Themes.Light) return _themeData[Themes.Dark];
return _themeData[theme!];
return _themeData[theme];
}
}
//Theme
return _themeData[theme!] ?? ThemeData();
return _themeData[theme] ?? ThemeData();
}
//Get all available fonts
@ -179,7 +187,7 @@ class Settings {
// AudioService.currentMediaItemStream.listen((event) async {
// if (event == null || event.artUri == null) return;
// this.primaryColor =
// await imagesDatabase.getPrimaryColor(event.artUri.toString());
// await imagesDatabase.getPrimaryColor(event.artUri.toString());
// updateTheme();
// });
//} else {
@ -258,7 +266,7 @@ class Settings {
//Check if is dark, can't use theme directly, because of system themes, and Theme.of(context).brightness broke
bool get isDark {
if (useSystemTheme!) {
if (useSystemTheme) {
if (SchedulerBinding.instance!.window.platformBrightness ==
Brightness.light) return false;
return true;
@ -272,7 +280,7 @@ class Settings {
TextTheme? get _textTheme => (font == 'Deezer')
? null
: GoogleFonts.getTextTheme(
font!,
font,
this.isDark
? ThemeData.dark().textTheme
: ThemeData.light().textTheme);
@ -292,6 +300,8 @@ class Settings {
sliderTheme: _sliderTheme,
toggleableActiveColor: primaryColor,
bottomAppBarColor: Color(0xfff5f5f5),
appBarTheme:
AppBarTheme(systemOverlayStyle: SystemUiOverlayStyle.light),
),
Themes.Dark: ThemeData(
textTheme: _textTheme,

View file

@ -70,6 +70,12 @@ Settings _$SettingsFromJson(Map<String, dynamic> json) => Settings(
..font = json['font'] as String? ?? 'Deezer'
..lyricsVisualizer = json['lyricsVisualizer'] as bool? ?? false
..displayMode = json['displayMode'] as int?
..enableFilledPlayButton = json['enableFilledPlayButton'] as bool? ?? true
..playerBackgroundOnLyrics =
json['playerBackgroundOnLyrics'] as bool? ?? false
..navigatorRouteType = _$enumDecodeNullable(
_$NavigatorRouteTypeEnumMap, json['navigatorRouteType']) ??
NavigatorRouteType.material
..primaryColor = Settings._colorFromJson(json['primaryColor'] as int?)
..useArtColor = json['useArtColor'] as bool? ?? false
..deezerLanguage = json['deezerLanguage'] as String? ?? 'en'
@ -117,6 +123,10 @@ Map<String, dynamic> _$SettingsToJson(Settings instance) => <String, dynamic>{
'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,
@ -181,6 +191,12 @@ const _$ThemesEnumMap = {
Themes.Black: 'Black',
};
const _$NavigatorRouteTypeEnumMap = {
NavigatorRouteType.blur_slide: 'blur_slide',
NavigatorRouteType.material: 'material',
NavigatorRouteType.cupertino: 'cupertino',
};
SpotifyCredentialsSave _$SpotifyCredentialsSaveFromJson(
Map<String, dynamic> json) =>
SpotifyCredentialsSave(

28
lib/ui/animated_blur.dart Normal file
View file

@ -0,0 +1,28 @@
import 'dart:ui';
import 'package:flutter/widgets.dart';
class AnimatedBlur extends StatelessWidget {
final Animation animation;
final double multiplier;
final Widget? child;
const AnimatedBlur({
Key? key,
required this.animation,
required this.multiplier,
this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animation,
child: child,
builder: (context, child) {
final sigma = animation.value * multiplier;
return BackdropFilter(
filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
child: child);
});
}
}

View file

@ -21,7 +21,7 @@ class ImagesDatabase {
Future<Color> getPrimaryColor(String url) async {
PaletteGenerator paletteGenerator = await getPaletteGenerator(url);
return paletteGenerator.colors.first;
return paletteGenerator.dominantColor!.color;
}
Future<bool> isDark(String url) async {
@ -113,8 +113,16 @@ class ZoomableImage extends StatefulWidget {
final String? url;
final bool rounded;
final double? width;
final bool enableHero;
final Object? heroTag;
ZoomableImage({required this.url, this.rounded = false, this.width});
ZoomableImage({
required this.url,
this.rounded = false,
this.width,
this.enableHero = true,
this.heroTag,
});
@override
_ZoomableImageState createState() => _ZoomableImageState();
@ -123,6 +131,8 @@ class ZoomableImage extends StatefulWidget {
class _ZoomableImageState extends State<ZoomableImage> {
PhotoViewController? controller;
bool photoViewOpened = false;
late final Object? _key =
widget.enableHero ? (widget.heroTag ?? UniqueKey()) : null;
@override
void initState() {
@ -141,28 +151,43 @@ class _ZoomableImageState extends State<ZoomableImage> {
@override
Widget build(BuildContext context) {
print('key: ' + _key.toString());
final image = CachedImage(
url: widget.url,
rounded: widget.rounded,
width: widget.width,
fullThumb: true,
);
final child = _key != null
? Hero(
tag: _key!,
child: image,
)
: image;
return GestureDetector(
child: Semantics(
child: CachedImage(
url: widget.url,
rounded: widget.rounded,
width: widget.width,
fullThumb: true,
),
child: child,
label: "Album art".i18n,
),
onTap: () {
Navigator.of(context).push(PageRouteBuilder(
opaque: false, // transparent background
pageBuilder: (context, _, __) {
pageBuilder: (context, animation, __) {
print('key: ' + _key.toString());
photoViewOpened = true;
return PhotoView(
imageProvider: CachedNetworkImageProvider(widget.url!),
maxScale: 8.0,
minScale: 0.2,
controller: controller,
backgroundDecoration:
BoxDecoration(color: Color.fromARGB(0x90, 0, 0, 0)));
return FadeTransition(
opacity: animation,
child: PhotoView(
imageProvider: CachedNetworkImageProvider(widget.url!),
maxScale: 8.0,
minScale: 0.2,
controller: controller,
heroAttributes: _key == null
? null
: PhotoViewHeroAttributes(tag: _key!),
backgroundDecoration: const BoxDecoration(
color: Color.fromARGB(0x90, 0, 0, 0))),
);
}));
},
);

View file

@ -202,10 +202,7 @@ class _AlbumDetailsState extends State<AlbumDetails> {
//Add to library
if (!album!.library!) {
await deezerAPI.addFavoriteAlbum(album!.id);
Fluttertoast.showToast(
msg: 'Added to library'.i18n,
toastLength: Toast.LENGTH_SHORT,
gravity: ToastGravity.BOTTOM);
ScaffoldMessenger.of(context).snack
setState(() => album!.library = true);
return;
}
@ -260,7 +257,7 @@ class _AlbumDetailsState extends State<AlbumDetails> {
),
...List.generate(
tracks.length,
(i) => TrackTile(tracks[i], onTap: () {
(i) => TrackTile(tracks[i]!, onTap: () {
playerHelper.playFromAlbum(
album!, tracks[i]!.id);
}, onHold: () {
@ -349,7 +346,7 @@ class ArtistDetails extends StatelessWidget {
FutureOr<Artist> _loadArtist(Artist artist) {
//Load artist from api if no albums
if ((this.artist.albums ?? []).length == 0) {
if ((artist.albums ?? []).length == 0) {
return deezerAPI.artist(artist.id);
}
return artist;
@ -364,9 +361,7 @@ class ArtistDetails extends StatelessWidget {
//Error / not done
if (snapshot.hasError) return ErrorScreen();
if (snapshot.connectionState != ConnectionState.done)
return Center(
child: CircularProgressIndicator(),
);
return const Center(child: CircularProgressIndicator());
return ListView(
children: <Widget>[
@ -499,9 +494,9 @@ class ArtistDetails extends StatelessWidget {
AlbumTile(
artist.highlight!.data,
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
Navigator.of(context).pushRoute(
builder: (context) =>
AlbumDetails(artist.highlight!.data)));
AlbumDetails(artist.highlight!.data));
},
),
const SizedBox(height: 8.0)
@ -536,13 +531,13 @@ class ArtistDetails extends StatelessWidget {
ListTile(
title: Text('Show more tracks'.i18n),
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
Navigator.of(context).pushRoute(
builder: (context) => TrackListScreen(
artist.topTracks,
QueueSource(
id: artist.id,
text: 'Top'.i18n + '${artist.name}',
source: 'topTracks'))));
source: 'topTracks')));
}),
FreezerDivider(),
//Albums
@ -562,10 +557,10 @@ class ArtistDetails extends StatelessWidget {
return ListTile(
title: Text('Show all albums'.i18n),
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
Navigator.of(context).pushRoute(
builder: (context) => DiscographyScreen(
artist: artist,
)));
));
});
}
//Top albums
@ -573,8 +568,8 @@ class ArtistDetails extends StatelessWidget {
return AlbumTile(
a,
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => AlbumDetails(a)));
Navigator.of(context)
.pushRoute(builder: (context) => AlbumDetails(a));
},
onHold: () {
MenuSheet m = MenuSheet(context);
@ -1232,7 +1227,7 @@ class _ShowScreenState extends State<ShowScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: FreezerAppBar(_show!.name),
appBar: FreezerAppBar(_show!.name!),
body: ListView(
children: [
Padding(

View file

@ -18,7 +18,7 @@ class DownloadsScreen extends StatefulWidget {
class _DownloadsScreenState extends State<DownloadsScreen> {
List<Download> downloads = [];
StreamSubscription? _stateSubscription;
late StreamSubscription _stateSubscription;
//Sublists
List<Download> get downloading => downloads
@ -70,8 +70,7 @@ class _DownloadsScreenState extends State<DownloadsScreen> {
@override
void dispose() {
_stateSubscription?.cancel();
_stateSubscription = null;
_stateSubscription.cancel();
super.dispose();
}
@ -96,13 +95,13 @@ class _DownloadsScreenState extends State<DownloadsScreen> {
),
IconButton(
icon: Icon(
downloadManager.running! ? Icons.stop : Icons.play_arrow,
downloadManager.running ? Icons.stop : Icons.play_arrow,
semanticLabel:
downloadManager.running! ? "Stop".i18n : "Start".i18n,
downloadManager.running ? "Stop".i18n : "Start".i18n,
),
onPressed: () {
setState(() {
if (downloadManager.running!)
if (downloadManager.running)
downloadManager.stop();
else
downloadManager.start();

View file

@ -32,41 +32,45 @@ class EmptyLeading extends StatelessWidget {
}
class FreezerAppBar extends StatelessWidget implements PreferredSizeWidget {
final String? title;
final List<Widget> actions;
final Widget? bottom;
final String title;
final List<Widget>? actions;
final PreferredSizeWidget? bottom;
//Should be specified if bottom is specified
final double height;
final SystemUiOverlayStyle? systemUiOverlayStyle;
const FreezerAppBar(this.title,
{this.actions = const [],
this.bottom,
this.height = 56.0,
this.systemUiOverlayStyle});
/// The appbar's backgroundColor, if left null,
/// it defaults to [ThemeData.scaffoldBackgroundColor]
final Color? backgroundColor;
final Color? foregroundColor;
final Brightness? brightness;
const FreezerAppBar(
this.title, {
this.actions,
this.bottom,
this.height = 56.0,
this.systemUiOverlayStyle,
this.backgroundColor,
this.brightness,
this.foregroundColor,
});
Size get preferredSize => Size.fromHeight(this.height);
@override
Widget build(BuildContext context) {
return Theme(
data: ThemeData(
primaryColor: (Theme.of(context).brightness == Brightness.light)
? Colors.white
: Colors.black),
child: AppBar(
systemOverlayStyle: systemUiOverlayStyle,
elevation: 0.0,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
title: Text(
title!,
style: TextStyle(
fontWeight: FontWeight.w900,
),
),
actions: actions,
bottom: bottom as PreferredSizeWidget?,
),
return AppBar(
systemOverlayStyle: systemUiOverlayStyle,
elevation: 0.0,
backgroundColor:
backgroundColor ?? Theme.of(context).scaffoldBackgroundColor,
title: Text(title, style: TextStyle(fontWeight: FontWeight.w900)),
actions: actions,
bottom: bottom,
foregroundColor:
foregroundColor ?? (settings.isDark ? Colors.white : Colors.black),
);
}
}

View file

@ -175,13 +175,13 @@ class HomepageRowSection extends StatelessWidget {
style: TextStyle(fontSize: 20.0),
),
onPressed: () =>
Navigator.of(context).push(MaterialPageRoute(
Navigator.of(context).pushRoute(
builder: (context) => Scaffold(
appBar: FreezerAppBar(section.title),
appBar: FreezerAppBar(section.title!),
body: SingleChildScrollView(
child: HomePageScreen(
channel:
DeezerChannel(target: section.pagePath))),
DeezerChannel(target: section.pagePath)),
),
)),
);
@ -245,8 +245,8 @@ class HomePageItemWidget extends StatelessWidget {
return AlbumCard(
item.value,
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => AlbumDetails(item.value)));
Navigator.of(context).pushRoute(
builder: (context) => AlbumDetails(item.value));
},
onHold: () {
MenuSheet m = MenuSheet(context);
@ -257,8 +257,8 @@ class HomePageItemWidget extends StatelessWidget {
return ArtistTile(
item.value,
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => ArtistDetails(item.value)));
Navigator.of(context).pushRoute(
builder: (context) => ArtistDetails(item.value));
},
onHold: () {
MenuSheet m = MenuSheet(context);
@ -269,8 +269,8 @@ class HomePageItemWidget extends StatelessWidget {
return PlaylistCardTile(
item.value,
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => PlaylistDetails(item.value)));
Navigator.of(context).pushRoute(
builder: (context) => PlaylistDetails(item.value));
},
onHold: () {
MenuSheet m = MenuSheet(context);
@ -281,22 +281,22 @@ class HomePageItemWidget extends StatelessWidget {
return ChannelTile(
item.value,
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
Navigator.of(context).pushRoute(
builder: (context) => Scaffold(
appBar: FreezerAppBar(item.value.title.toString()),
body: SingleChildScrollView(
child: HomePageScreen(
channel: item.value,
)),
)));
));
},
);
case HomePageItemType.SHOW:
return ShowCard(
item.value,
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => ShowScreen(item.value)));
Navigator.of(context).pushRoute(
builder: (context) => ShowScreen(item.value));
},
);
default:

View file

@ -36,8 +36,8 @@ class LibraryAppBar extends StatelessWidget implements PreferredSizeWidget {
semanticLabel: "Download".i18n,
),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => DownloadsScreen()));
Navigator.of(context)
.pushRoute(builder: (context) => DownloadsScreen());
},
),
IconButton(
@ -46,8 +46,8 @@ class LibraryAppBar extends StatelessWidget implements PreferredSizeWidget {
semanticLabel: "Settings".i18n,
),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => SettingsScreen()));
Navigator.of(context)
.pushRoute(builder: (context) => SettingsScreen());
},
),
],
@ -65,7 +65,7 @@ class LibraryScreen extends StatelessWidget {
Container(
height: 4.0,
),
if (!downloadManager.running! && downloadManager.queueSize! > 0)
if (!downloadManager.running && downloadManager.queueSize! > 0)
ListTile(
title: Text('Downloads'.i18n),
leading: LeadingIcon(Icons.file_download, color: Colors.grey),
@ -74,8 +74,8 @@ class LibraryScreen extends StatelessWidget {
.i18n),
onTap: () {
downloadManager.start();
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => DownloadsScreen()));
Navigator.of(context)
.pushRoute(builder: (context) => DownloadsScreen());
},
),
ListTile(
@ -97,32 +97,32 @@ class LibraryScreen extends StatelessWidget {
title: Text('Tracks'.i18n),
leading: LeadingIcon(Icons.audiotrack, color: Color(0xffbe3266)),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => LibraryTracks()));
Navigator.of(context)
.pushRoute(builder: (context) => LibraryTracks());
},
),
ListTile(
title: Text('Albums'.i18n),
leading: LeadingIcon(Icons.album, color: Color(0xff4b2e7e)),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => LibraryAlbums()));
Navigator.of(context)
.pushRoute(builder: (context) => LibraryAlbums());
},
),
ListTile(
title: Text('Artists'.i18n),
leading: LeadingIcon(Icons.recent_actors, color: Color(0xff384697)),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => LibraryArtists()));
Navigator.of(context)
.pushRoute(builder: (context) => LibraryArtists());
},
),
ListTile(
title: Text('Playlists'.i18n),
leading: LeadingIcon(Icons.playlist_play, color: Color(0xff0880b5)),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => LibraryPlaylists()));
Navigator.of(context)
.pushRoute(builder: (context) => LibraryPlaylists());
},
),
FreezerDivider(),
@ -130,8 +130,8 @@ class LibraryScreen extends StatelessWidget {
title: Text('History'.i18n),
leading: LeadingIcon(Icons.history, color: Color(0xff009a85)),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => HistoryScreen()));
Navigator.of(context)
.pushRoute(builder: (context) => HistoryScreen());
},
),
FreezerDivider(),
@ -142,8 +142,8 @@ class LibraryScreen extends StatelessWidget {
onTap: () {
//Show progress
if (importer.done || importer.busy) {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => ImporterStatusScreen()));
Navigator.of(context)
.pushRoute(builder: (context) => ImporterStatusScreen());
return;
}
@ -161,8 +161,8 @@ class LibraryScreen extends StatelessWidget {
.i18n),
onTap: () {
Navigator.of(context).pop();
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => SpotifyImporterV1()));
Navigator.of(context).pushRoute(
builder: (context) => SpotifyImporterV1());
},
),
ListTile(
@ -173,8 +173,8 @@ class LibraryScreen extends StatelessWidget {
.i18n),
onTap: () {
Navigator.of(context).pop();
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => SpotifyImporterV2()));
Navigator.of(context).pushRoute(
builder: (context) => SpotifyImporterV2());
},
)
],
@ -509,13 +509,13 @@ class _LibraryTracksState extends State<LibraryTracks> {
? _sorted[i]
: tracks![i];
return TrackTile(
t,
t!,
onTap: () {
playerHelper.playFromTrackList(
(tracks!.length == (trackCount ?? 0))
? _sorted
: tracks!,
t!.id,
t.id,
QueueSource(
id: deezerAPI.favoritesPlaylistId,
text: 'Favorites'.i18n,
@ -523,7 +523,7 @@ class _LibraryTracksState extends State<LibraryTracks> {
},
onHold: () {
MenuSheet m = MenuSheet(context);
m.defaultTrackMenu(t!, onRemove: () {
m.defaultTrackMenu(t, onRemove: () {
setState(() {
tracks!.removeWhere((track) => t.id == track!.id);
});
@ -553,19 +553,18 @@ class _LibraryTracksState extends State<LibraryTracks> {
...List.generate(allTracks.length, (i) {
Track? t = allTracks[i];
return TrackTile(
t,
t!,
onTap: () {
playerHelper.playFromTrackList(
allTracks,
t!.id,
t.id,
QueueSource(
id: 'allTracks',
text: 'All offline tracks'.i18n,
source: 'offline'));
},
onHold: () {
MenuSheet m = MenuSheet(context);
m.defaultTrackMenu(t!);
MenuSheet(context).defaultTrackMenu(t);
},
);
})
@ -714,8 +713,8 @@ class _LibraryAlbumsState extends State<LibraryAlbums> {
return AlbumTile(
a,
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => AlbumDetails(a)));
Navigator.of(context)
.pushRoute(builder: (context) => AlbumDetails(a));
},
onHold: () async {
MenuSheet m = MenuSheet(context);
@ -751,8 +750,8 @@ class _LibraryAlbumsState extends State<LibraryAlbums> {
return AlbumTile(
a,
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => AlbumDetails(a)));
Navigator.of(context).pushRoute(
builder: (context) => AlbumDetails(a));
},
onHold: () async {
MenuSheet m = MenuSheet(context);
@ -919,8 +918,8 @@ class _LibraryArtistsState extends State<LibraryArtists> {
return ArtistHorizontalTile(
a,
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => ArtistDetails(a)));
Navigator.of(context)
.pushRoute(builder: (context) => ArtistDetails(a));
},
onHold: () {
MenuSheet m = MenuSheet(context);
@ -1118,9 +1117,8 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
PlaylistTile(
favoritesPlaylist,
onTap: () async {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) =>
PlaylistDetails(favoritesPlaylist)));
Navigator.of(context).pushRoute(
builder: (context) => PlaylistDetails(favoritesPlaylist));
},
onHold: () {
MenuSheet m = MenuSheet(context);
@ -1134,8 +1132,8 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
Playlist p = _sorted[i];
return PlaylistTile(
p,
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (context) => PlaylistDetails(p))),
onTap: () => Navigator.of(context)
.pushRoute(builder: (context) => PlaylistDetails(p)),
onHold: () {
MenuSheet m = MenuSheet(context);
m.defaultPlaylistMenu(p, onRemove: () {
@ -1175,9 +1173,8 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
Playlist p = playlists[i];
return PlaylistTile(
p,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => PlaylistDetails(p))),
onTap: () => Navigator.of(context).pushRoute(
builder: (context) => PlaylistDetails(p)),
onHold: () {
MenuSheet m = MenuSheet(context);
m.defaultPlaylistMenu(p, onRemove: () {

View file

@ -215,9 +215,9 @@ class _LoginWidgetState extends State<LoginWidget> {
OutlinedButton(
child: Text('Login using browser'.i18n),
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(
Navigator.of(context).pushRoute(
builder: (context) =>
LoginBrowser(_update)));
LoginBrowser(_update));
},
),
OutlinedButton(

View file

@ -1,257 +0,0 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:freezer/api/deezer.dart';
import 'package:freezer/api/definitions.dart';
import 'package:freezer/api/player.dart';
import 'package:freezer/settings.dart';
import 'package:freezer/ui/elements.dart';
import 'package:freezer/translations.i18n.dart';
import 'package:freezer/ui/error.dart';
import 'package:freezer/ui/player_bar.dart';
class LyricsScreen extends StatefulWidget {
LyricsScreen({Key? key}) : super(key: key);
@override
_LyricsScreenState createState() => _LyricsScreenState();
}
class _LyricsScreenState extends State<LyricsScreen> {
late StreamSubscription _mediaItemSub;
late StreamSubscription _playbackStateSub;
int? _currentIndex = -1;
int? _prevIndex = -1;
ScrollController _controller = ScrollController();
final double height = 90;
Lyrics? lyrics;
bool _loading = true;
Object? _error;
bool _freeScroll = false;
bool _animatedScroll = false;
Future _loadForId(String trackId) async {
//Fetch
if (_loading == false && lyrics != null) {
setState(() {
_freeScroll = false;
_loading = true;
lyrics = null;
});
}
try {
Lyrics l = await deezerAPI.lyrics(trackId);
setState(() {
_loading = false;
lyrics = l;
});
_scrollToLyric();
} catch (e) {
setState(() {
_error = e;
});
}
}
Future<void> _scrollToLyric() async {
//Lyric height, screen height, appbar height
double _scrollTo = (height * _currentIndex!) -
(MediaQuery.of(context).size.height / 2) +
(height / 2) +
56;
if (0 > _scrollTo) return;
_animatedScroll = true;
await _controller.animateTo(_scrollTo,
duration: Duration(milliseconds: 250), curve: Curves.ease);
_animatedScroll = false;
}
@override
void initState() {
SchedulerBinding.instance!.addPostFrameCallback((_) {
//Enable visualizer
// if (settings.lyricsVisualizer) playerHelper.startVisualizer();
_playbackStateSub = AudioService.position.listen((position) {
if (_loading) return;
_currentIndex =
lyrics?.lyrics?.lastIndexWhere((l) => l.offset! <= position);
//Scroll to current lyric
if (_currentIndex! < 0) return;
if (_prevIndex == _currentIndex) return;
//Update current lyric index
setState(() => null);
_prevIndex = _currentIndex;
if (_freeScroll) return;
_scrollToLyric();
});
});
if (audioHandler.mediaItem.value != null)
_loadForId(audioHandler.mediaItem.value!.id);
/// Track change = ~exit~ reload lyrics
_mediaItemSub = audioHandler.mediaItem.listen((mediaItem) {
if (mediaItem == null) return;
if (_controller.hasClients) _controller.jumpTo(0.0);
_loadForId(mediaItem.id);
});
super.initState();
}
@override
void dispose() {
_mediaItemSub.cancel();
_playbackStateSub.cancel();
//Stop visualizer
// if (settings.lyricsVisualizer) playerHelper.stopVisualizer();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: FreezerAppBar('Lyrics'.i18n,
systemUiOverlayStyle: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: Theme.of(context).scaffoldBackgroundColor,
systemNavigationBarDividerColor: Color(
Theme.of(context).scaffoldBackgroundColor.value - 0x00111111),
systemNavigationBarIconBrightness: Brightness.light,
)),
body: SafeArea(
child: Column(
children: [
Theme(
data: settings.themeData!.copyWith(
textButtonTheme: TextButtonThemeData(
style: ButtonStyle(
foregroundColor:
MaterialStateProperty.all(Colors.white)))),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_freeScroll && !_loading)
TextButton(
onPressed: () {
setState(() => _freeScroll = false);
_scrollToLyric();
},
child: Text(
_currentIndex! >= 0
? (lyrics?.lyrics?[_currentIndex!].text ?? '...')
: '...',
textAlign: TextAlign.center,
),
style: ButtonStyle(
foregroundColor:
MaterialStateProperty.all(Colors.white)))
],
),
),
Expanded(
child: Stack(children: [
//Lyrics
_error != null
?
//Shouldn't really happen, empty lyrics have own text
ErrorScreen(message: _error.toString())
:
// Loading lyrics
_loading
? Center(child: CircularProgressIndicator())
: NotificationListener(
onNotification: (Notification notification) {
if (_freeScroll ||
notification is! ScrollStartNotification)
return false;
if (!_animatedScroll && !_loading)
setState(() => _freeScroll = true);
return false;
},
child: ListView.builder(
controller: _controller,
padding: EdgeInsets.fromLTRB(
0,
0,
0,
settings.lyricsVisualizer! && false
? 100
: 0),
itemCount: lyrics!.lyrics!.length,
itemBuilder: (BuildContext context, int i) {
return Padding(
padding:
EdgeInsets.symmetric(horizontal: 8.0),
child: Container(
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(8.0),
color: _currentIndex == i
? Colors.grey.withOpacity(0.25)
: Colors.transparent,
),
height: height,
child: InkWell(
borderRadius:
BorderRadius.circular(8.0),
onTap: lyrics!.id != null
? () => audioHandler.seek(
lyrics!.lyrics![i].offset!)
: null,
child: Center(
child: Text(
lyrics!.lyrics![i].text!,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 26.0,
fontWeight:
(_currentIndex == i)
? FontWeight.bold
: FontWeight
.normal),
),
))));
},
)),
//Visualizer
//if (settings.lyricsVisualizer)
// Positioned(
// bottom: 0,
// left: 0,
// right: 0,
// child: StreamBuilder(
// stream: playerHelper.visualizerStream,
// builder: (BuildContext context, AsyncSnapshot snapshot) {
// List<double> data = snapshot.data ?? [];
// double width = MediaQuery.of(context).size.width /
// data.length; //- 0.25;
// return Row(
// crossAxisAlignment: CrossAxisAlignment.end,
// children: List.generate(
// data.length,
// (i) => AnimatedContainer(
// duration: Duration(milliseconds: 130),
// color: settings.primaryColor,
// height: data[i] * 100,
// width: width,
// )),
// );
// }),
// ),
]),
),
PlayerBar(shouldHandleClicks: false),
],
),
),
);
}
}

248
lib/ui/lyrics_screen.dart Normal file
View file

@ -0,0 +1,248 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
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/settings.dart';
import 'package:freezer/ui/elements.dart';
import 'package:freezer/translations.i18n.dart';
import 'package:freezer/ui/error.dart';
import 'package:freezer/ui/player_bar.dart';
import 'package:freezer/ui/player_screen.dart';
class LyricsScreen extends StatefulWidget {
LyricsScreen({Key? key}) : super(key: key);
@override
_LyricsScreenState createState() => _LyricsScreenState();
}
class _LyricsScreenState extends State<LyricsScreen> {
late StreamSubscription _mediaItemSub;
late StreamSubscription _playbackStateSub;
int? _currentIndex = -1;
int? _prevIndex = -1;
ScrollController _controller = ScrollController();
final double height = 90;
Lyrics? lyrics;
bool _loading = true;
Object? _error;
bool _freeScroll = false;
bool _animatedScroll = false;
Future _loadForId(String trackId) async {
//Fetch
if (_loading == false && lyrics != null) {
setState(() {
_freeScroll = false;
_loading = true;
lyrics = null;
});
}
try {
Lyrics l = await deezerAPI.lyrics(trackId);
setState(() {
_loading = false;
lyrics = l;
});
_scrollToLyric();
} catch (e) {
setState(() {
_error = e;
});
}
}
Future<void> _scrollToLyric() async {
if (!_controller.hasClients) return;
//Lyric height, screen height, appbar height
double _scrollTo = (height * _currentIndex!) -
(MediaQuery.of(context).size.height / 4 + height / 2);
print(
'${height * _currentIndex!}, ${MediaQuery.of(context).size.height / 2}');
if (0 > _scrollTo) return;
if (_scrollTo > _controller.position.maxScrollExtent)
_scrollTo = _controller.position.maxScrollExtent;
_animatedScroll = true;
await _controller.animateTo(_scrollTo,
duration: Duration(milliseconds: 250), curve: Curves.ease);
_animatedScroll = false;
}
@override
void initState() {
SchedulerBinding.instance!.addPostFrameCallback((_) {
//Enable visualizer
// if (settings.lyricsVisualizer) playerHelper.startVisualizer();
_playbackStateSub = AudioService.position.listen((position) {
if (_loading) return;
_currentIndex =
lyrics?.lyrics?.lastIndexWhere((l) => l.offset! <= position);
//Scroll to current lyric
if (_currentIndex! < 0) return;
if (_prevIndex == _currentIndex) return;
//Update current lyric index
setState(() => null);
_prevIndex = _currentIndex;
if (_freeScroll) return;
_scrollToLyric();
});
});
if (audioHandler.mediaItem.value != null)
_loadForId(audioHandler.mediaItem.value!.id);
/// Track change = ~exit~ reload lyrics
_mediaItemSub = audioHandler.mediaItem.listen((mediaItem) {
if (mediaItem == null) return;
if (_controller.hasClients) _controller.jumpTo(0.0);
_loadForId(mediaItem.id);
});
super.initState();
}
@override
void dispose() {
_mediaItemSub.cancel();
_playbackStateSub.cancel();
//Stop visualizer
// if (settings.lyricsVisualizer) playerHelper.stopVisualizer();
super.dispose();
}
@override
Widget build(BuildContext context) {
return PlayerScreenBackground(
enabled: settings.playerBackgroundOnLyrics,
appBar: FreezerAppBar(
'Lyrics'.i18n,
systemUiOverlayStyle: PlayerScreenBackground.getSystemUiOverlayStyle(
context,
enabled: settings.playerBackgroundOnLyrics),
backgroundColor: Colors.transparent,
),
child: Column(
children: [
if (_freeScroll && !_loading)
Center(
child: TextButton(
onPressed: () {
setState(() => _freeScroll = false);
_scrollToLyric();
},
child: Text(
_currentIndex! >= 0
? (lyrics?.lyrics?[_currentIndex!].text ?? '...')
: '...',
textAlign: TextAlign.center,
),
style: ButtonStyle(
foregroundColor:
MaterialStateProperty.all(Colors.white))),
),
Expanded(
child: Stack(children: [
//Lyrics
_error != null
?
//Shouldn't really happen, empty lyrics have own text
ErrorScreen(message: _error.toString())
:
// Loading lyrics
_loading
? Center(child: CircularProgressIndicator())
: NotificationListener<ScrollStartNotification>(
onNotification:
(ScrollStartNotification notification) {
final extentDiff =
(notification.metrics.extentBefore -
notification.metrics.extentAfter)
.abs();
// avoid accidental clicks
const extentThreshold = 10.0;
if (extentDiff >= extentThreshold &&
!_animatedScroll &&
!_loading &&
!_freeScroll) {
setState(() => _freeScroll = true);
}
return false;
},
child: ListView.builder(
controller: _controller,
itemCount: lyrics!.lyrics!.length,
itemBuilder: (BuildContext context, int i) {
return Padding(
padding:
EdgeInsets.symmetric(horizontal: 8.0),
child: Container(
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(8.0),
color: _currentIndex == i
? Colors.grey.withOpacity(0.25)
: Colors.transparent,
),
height: height,
child: InkWell(
borderRadius:
BorderRadius.circular(8.0),
onTap: lyrics!.id != null
? () => audioHandler.seek(
lyrics!.lyrics![i].offset!)
: null,
child: Center(
child: Text(
lyrics!.lyrics![i].text!,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 26.0,
fontWeight:
(_currentIndex == i)
? FontWeight.bold
: FontWeight.normal),
),
))));
},
)),
//Visualizer
//if (settings.lyricsVisualizer)
// Positioned(
// bottom: 0,
// left: 0,
// right: 0,
// child: StreamBuilder(
// stream: playerHelper.visualizerStream,
// builder: (BuildContext context, AsyncSnapshot snapshot) {
// List<double> data = snapshot.data ?? [];
// double width = MediaQuery.of(context).size.width /
// data.length; //- 0.25;
// return Row(
// crossAxisAlignment: CrossAxisAlignment.end,
// children: List.generate(
// data.length,
// (i) => AnimatedContainer(
// duration: Duration(milliseconds: 130),
// color: settings.primaryColor,
// height: data[i] * 100,
// width: width,
// )),
// );
// }),
// ),
]),
),
Divider(height: 1.0, thickness: 1.0),
PlayerBar(
shouldHandleClicks: false, backgroundColor: Colors.transparent),
],
),
);
}
}

View file

@ -2,6 +2,8 @@ import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:freezer/api/definitions.dart';
import 'package:freezer/page_routes/fade.dart';
import 'package:freezer/settings.dart';
import 'package:freezer/translations.i18n.dart';
@ -11,7 +13,14 @@ import 'player_screen.dart';
class PlayerBar extends StatefulWidget {
final bool shouldHandleClicks;
const PlayerBar({Key? key, this.shouldHandleClicks = true}) : super(key: key);
final bool shouldHaveHero;
final Color? backgroundColor;
const PlayerBar({
Key? key,
this.shouldHandleClicks = true,
this.shouldHaveHero = true,
this.backgroundColor,
}) : super(key: key);
@override
_PlayerBarState createState() => _PlayerBarState();
@ -21,6 +30,7 @@ class _PlayerBarState extends State<PlayerBar> {
final double iconSize = 28;
late StreamSubscription mediaItemSub;
late bool _isNothingPlaying = audioHandler.mediaItem.value == null;
final focusNode = FocusNode();
double parsePosition(Duration position) {
if (audioHandler.mediaItem.value == null) return 0.0;
@ -40,8 +50,12 @@ class _PlayerBarState extends State<PlayerBar> {
super.initState();
}
Color get backgroundColor =>
widget.backgroundColor ?? Theme.of(context).bottomAppBarColor;
@override
void dispose() {
focusNode.dispose();
mediaItemSub.cancel();
super.dispose();
}
@ -50,7 +64,6 @@ class _PlayerBarState extends State<PlayerBar> {
@override
Widget build(BuildContext context) {
var focusNode = FocusNode();
return _isNothingPlaying
? const SizedBox()
: GestureDetector(
@ -72,35 +85,35 @@ class _PlayerBarState extends State<PlayerBar> {
child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
StreamBuilder<MediaItem?>(
stream: audioHandler.mediaItem,
initialData: audioHandler.mediaItem.valueOrNull,
builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox();
final currentMediaItem = snapshot.data!;
return DecoratedBox(
final image = CachedImage(
width: 50,
height: 50,
url: currentMediaItem.extras!['thumb'] ??
audioHandler.mediaItem.value!.artUri as String?,
);
final leadingWidget = widget.shouldHaveHero
? Hero(tag: currentMediaItem.id, child: image)
: image;
return Material(
// For Android TV: indicate focus by grey
decoration: BoxDecoration(
color: focusNode.hasFocus
? Colors.black26
: Theme.of(context).bottomAppBarColor),
color: focusNode.hasFocus
? Color.lerp(backgroundColor, Colors.grey, 0.26)
: backgroundColor,
child: ListTile(
dense: true,
focusNode: focusNode,
contentPadding:
EdgeInsets.symmetric(horizontal: 8.0),
onTap: widget.shouldHandleClicks
? () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (BuildContext context) =>
PlayerScreen()));
}
? _pushPlayerScreen
: null,
leading: CachedImage(
width: 50,
height: 50,
url: currentMediaItem.extras!['thumb'] ??
audioHandler.mediaItem.value!.artUri
as String?,
),
leading: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: leadingWidget),
title: Text(
currentMediaItem.displayTitle!,
overflow: TextOverflow.clip,
@ -135,8 +148,6 @@ class _PlayerBarState extends State<PlayerBar> {
stream: AudioService.position,
builder: (context, snapshot) {
return LinearProgressIndicator(
backgroundColor:
Theme.of(context).primaryColor.withOpacity(0.1),
value: parsePosition(snapshot.data ?? Duration.zero),
);
}),
@ -144,6 +155,15 @@ class _PlayerBarState extends State<PlayerBar> {
]),
);
}
void _pushPlayerScreen() {
final builder = (BuildContext context) => PlayerScreen();
if (settings.blurPlayerBackground) {
Navigator.of(context).push(FadePageRoute(builder: builder));
return;
}
Navigator.of(context).pushRoute(builder: builder);
}
}
class PrevNextButton extends StatelessWidget {
@ -154,8 +174,8 @@ class PrevNextButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return StreamBuilder<List<MediaItem?>>(
stream: audioHandler.queue,
return StreamBuilder<MediaItem?>(
stream: audioHandler.mediaItem,
builder: (context, snapshot) {
if (!prev) {
return IconButton(
@ -165,7 +185,7 @@ class PrevNextButton extends StatelessWidget {
),
iconSize: size,
onPressed:
playerHelper.queueIndex == (snapshot.data ?? []).length - 1
playerHelper.queueIndex == audioHandler.queue.value.length - 1
? null
: () => audioHandler.skipToNext(),
);
@ -189,7 +209,18 @@ class PrevNextButton extends StatelessWidget {
class PlayPauseButton extends StatefulWidget {
final double size;
PlayPauseButton(this.size, {Key? key}) : super(key: key);
final bool filled;
final Color? iconColor;
/// The color of the card if [filled] is true
final Color? color;
const PlayPauseButton(
this.size, {
Key? key,
this.filled = false,
this.color,
this.iconColor,
}) : super(key: key);
@override
_PlayPauseButtonState createState() => _PlayPauseButtonState();
@ -197,64 +228,91 @@ class PlayPauseButton extends StatefulWidget {
class _PlayPauseButtonState extends State<PlayPauseButton>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
late AnimationController _controller =
AnimationController(vsync: this, duration: Duration(milliseconds: 200));
late Animation<double> _animation =
CurvedAnimation(parent: _controller, curve: Curves.easeInOut);
late StreamSubscription _subscription;
late bool _canPlay = audioHandler.playbackState.value.playing ||
audioHandler.playbackState.value.processingState ==
AudioProcessingState.ready;
@override
void initState() {
_controller =
AnimationController(vsync: this, duration: Duration(milliseconds: 200));
_animation = CurvedAnimation(parent: _controller, curve: Curves.easeInOut);
_subscription = audioHandler.playbackState.listen((playbackState) {
if (playbackState.playing ||
playbackState.processingState == AudioProcessingState.ready) {
if (playbackState.playing)
_controller.forward();
else
_controller.reverse();
if (!_canPlay) setState(() => _canPlay = true);
return;
}
setState(() => _canPlay = false);
});
super.initState();
}
@override
void dispose() {
_subscription.cancel();
_controller.dispose();
super.dispose();
}
void _playPause() {
if (audioHandler.playbackState.value.playing)
audioHandler.pause();
else
audioHandler.play();
}
@override
Widget build(BuildContext context) {
return StreamBuilder(
stream: audioHandler.playbackState,
builder: (context, snapshot) {
//Animated icon by pato05
bool _playing = audioHandler.playbackState.value.playing;
if (_playing ||
audioHandler.playbackState.value.processingState ==
AudioProcessingState.ready) {
if (_playing)
_controller.forward();
else
_controller.reverse();
return IconButton(
icon: AnimatedIcon(
icon: AnimatedIcons.play_pause,
progress: _animation,
semanticLabel: _playing ? "Pause".i18n : "Play".i18n,
),
iconSize: widget.size,
onPressed: _playing
? () => audioHandler.pause()
: () => audioHandler.play());
}
switch (audioHandler.playbackState.value.processingState) {
//Stopped/Error
case AudioProcessingState.error:
case AudioProcessingState.idle:
return SizedBox(width: widget.size, height: widget.size);
//Loading, connecting, rewinding...
default:
return SizedBox(
width: widget.size,
height: widget.size,
child: const CircularProgressIndicator(),
);
}
},
);
final Widget? child;
if (_canPlay) {
final icon = AnimatedIcon(
icon: AnimatedIcons.play_pause,
progress: _animation,
semanticLabel: audioHandler.playbackState.value.playing
? 'Pause'.i18n
: 'Play'.i18n,
);
if (!widget.filled)
return IconButton(
color: widget.iconColor,
icon: icon,
iconSize: widget.size,
onPressed: _playPause);
child = InkWell(
borderRadius: BorderRadius.circular(50.0),
child: IconTheme.merge(
child: Center(child: icon),
data: IconThemeData(
size: widget.size / 2, color: widget.iconColor)),
onTap: _playPause);
} else
switch (audioHandler.playbackState.value.processingState) {
//Stopped/Error
case AudioProcessingState.error:
case AudioProcessingState.idle:
child = null;
break;
//Loading, connecting, rewinding...
default:
child = const Center(child: CircularProgressIndicator());
break;
}
if (widget.filled)
return SizedBox.square(
dimension: widget.size,
child: Card(
color: widget.color,
elevation: 2.0,
shape: CircleBorder(),
child: child));
else
return SizedBox.square(dimension: widget.size, child: child);
}
}

View file

@ -1,5 +1,6 @@
import 'dart:math';
import 'dart:ui';
import 'dart:convert';
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -7,165 +8,199 @@ import 'package:audio_service/audio_service.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.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/download.dart';
import 'package:freezer/api/player.dart';
import 'package:freezer/page_routes/fade.dart';
import 'package:freezer/settings.dart';
import 'package:freezer/translations.i18n.dart';
import 'package:freezer/ui/elements.dart';
import 'package:freezer/ui/lyrics.dart';
import 'package:freezer/ui/cached_image.dart';
import 'package:freezer/ui/lyrics_screen.dart';
import 'package:freezer/ui/menu.dart';
import 'package:freezer/ui/player_bar.dart';
import 'package:freezer/ui/queue_screen.dart';
import 'package:freezer/ui/settings_screen.dart';
import 'package:freezer/ui/tiles.dart';
import 'package:just_audio/just_audio.dart';
import 'package:marquee/marquee.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:photo_view/photo_view.dart';
import 'cached_image.dart';
import '../api/definitions.dart';
import 'player_bar.dart';
import 'dart:ui';
import 'dart:convert';
import 'dart:async';
import 'package:provider/provider.dart';
//Changing item in queue view and pressing back causes the pageView to skip song
bool pageViewLock = false;
//So can be updated when going back from lyrics
late Function updateColor;
const _blurStrength = 90.0;
class PlayerScreen extends StatefulWidget {
static const _blurStrength = 50.0;
/// A simple [ChangeNotifier] that listens to the [AudioHandler.mediaItem] stream and
/// notifies its listeners when background changes
class BackgroundProvider extends ChangeNotifier {
Color _dominantColor;
ImageProvider? _imageProvider;
StreamSubscription? _mediaItemSub;
BackgroundProvider(this._dominantColor);
@override
_PlayerScreenState createState() => _PlayerScreenState();
}
class _PlayerScreenState extends State<PlayerScreen> {
LinearGradient? _bgGradient;
late StreamSubscription _mediaItemSub;
late StreamSubscription _playerStateSub;
ImageProvider? _blurImage;
bool _wasConnected = true;
//Calculate background color
Future _updateColor() async {
if (!settings.colorGradientBackground! && !settings.blurPlayerBackground!)
/// Calculate background color from [mediaItem]
///
/// Warning: this function is expensive to call, and should only be called when songs change!
Future _updateColor(MediaItem mediaItem) async {
if (!settings.colorGradientBackground && !settings.blurPlayerBackground)
return;
final imageProvider = CachedNetworkImageProvider(
audioHandler.mediaItem.value!.extras!['thumb'] ??
audioHandler.mediaItem.value!.artUri as String);
//BG Image
if (settings.blurPlayerBackground!)
setState(() => _blurImage = imageProvider);
mediaItem.extras!['thumb'] ?? mediaItem.artUri as String);
//Run in isolate
PaletteGenerator palette =
await PaletteGenerator.fromImageProvider(imageProvider);
if (settings.colorGradientBackground!) {
//Run in isolate
PaletteGenerator palette =
await PaletteGenerator.fromImageProvider(imageProvider);
setState(() => _bgGradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
palette.dominantColor!.color.withOpacity(0.7),
Color.fromARGB(0, 0, 0, 0)
],
stops: [
0.0,
0.6
]));
}
}
void _playbackStateChanged() {
// if (audioHandler.mediaItem.value == null) {
// //playerHelper.startService();
// setState(() => _wasConnected = false);
// } else if (!_wasConnected) setState(() => _wasConnected = true);
_dominantColor = palette.dominantColor!.color;
_imageProvider = settings.blurPlayerBackground ? imageProvider : null;
notifyListeners();
}
@override
void initState() {
Future.delayed(Duration(milliseconds: 600), _updateColor);
_playbackStateChanged();
_mediaItemSub = audioHandler.mediaItem.listen((event) {
_playbackStateChanged();
_updateColor();
void addListener(VoidCallback listener) {
_mediaItemSub ??= audioHandler.mediaItem.listen((mediaItem) {
if (mediaItem == null) return;
_updateColor(mediaItem);
});
_playerStateSub =
audioHandler.playbackState.listen((_) => _playbackStateChanged());
super.addListener(listener);
}
updateColor = this._updateColor;
super.initState();
@override
void removeListener(VoidCallback listener) {
super.removeListener(listener);
if (!hasListeners && _mediaItemSub != null) {
_mediaItemSub!.cancel();
_mediaItemSub = null;
}
}
@override
void dispose() {
_mediaItemSub.cancel();
_playerStateSub.cancel();
_mediaItemSub?.cancel();
super.dispose();
}
Color get dominantColor => _dominantColor;
ImageProvider<Object>? get imageProvider => _imageProvider;
}
class PlayerScreen extends StatelessWidget {
const PlayerScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final defaultColor = Theme.of(context).cardColor;
return ChangeNotifierProvider(
create: (context) => BackgroundProvider(defaultColor),
child: PlayerScreenBackground(
child: OrientationBuilder(
builder: (context, orientation) =>
orientation == Orientation.landscape
? PlayerScreenHorizontal()
: PlayerScreenVertical())),
);
}
}
/// Will change the background based on [BackgroundProvider],
/// it will wrap the [child] in a [Scaffold] and [SafeArea] widget
class PlayerScreenBackground extends StatelessWidget {
final Widget child;
final bool enabled;
final PreferredSizeWidget? appBar;
const PlayerScreenBackground({
required this.child,
this.enabled = true,
this.appBar,
});
Widget _buildChild(
BuildContext context, BackgroundProvider provider, Widget child) {
return Stack(children: [
if (provider.imageProvider != null || settings.colorGradientBackground)
Positioned.fill(
child: provider.imageProvider != null
? ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: _blurStrength,
sigmaY: _blurStrength,
),
child: DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
image: provider.imageProvider!,
fit: BoxFit.cover,
colorFilter: ColorFilter.mode(
Colors.white
.withOpacity(settings.isDark ? 0.5 : 0.8),
BlendMode.dstATop),
)),
),
)
: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
provider.dominantColor,
Theme.of(context).scaffoldBackgroundColor,
],
stops: [0.0, 0.6],
)),
)),
child,
]);
}
static SystemUiOverlayStyle getSystemUiOverlayStyle(BuildContext context,
{bool enabled = true}) {
final hasBackground = enabled &&
(settings.blurPlayerBackground || settings.colorGradientBackground);
final color = hasBackground
? Colors.transparent
: Theme.of(context).scaffoldBackgroundColor;
final brightness = hasBackground
? Brightness.light
: (ThemeData.estimateBrightnessForColor(color) == Brightness.light
? Brightness.dark
: Brightness.light);
return SystemUiOverlayStyle(
statusBarColor: color,
statusBarBrightness: brightness,
statusBarIconBrightness: brightness,
systemNavigationBarIconBrightness: brightness,
systemNavigationBarColor: color,
systemNavigationBarDividerColor: color,
);
}
@override
Widget build(BuildContext context) {
final hasBackground =
settings.blurPlayerBackground! || settings.colorGradientBackground!;
final hasBackground = enabled &&
(settings.blurPlayerBackground || settings.colorGradientBackground);
final color = hasBackground
? Colors.transparent
: Theme.of(context).scaffoldBackgroundColor;
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle(
statusBarColor: color,
statusBarBrightness: Brightness.light,
statusBarIconBrightness: Brightness.light,
systemNavigationBarIconBrightness: Brightness.light,
systemNavigationBarColor: color,
systemNavigationBarDividerColor: color,
),
child: Stack(
children: [
if (hasBackground)
Positioned.fill(
child: ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: PlayerScreen._blurStrength,
sigmaY: PlayerScreen._blurStrength,
tileMode: TileMode.mirror),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: _bgGradient,
image: _blurImage == null
? null
: DecorationImage(
image: _blurImage!,
fit: BoxFit.cover,
colorFilter: ColorFilter.mode(
Colors.white.withOpacity(0.5),
BlendMode.dstATop))),
),
),
),
Scaffold(
backgroundColor: hasBackground ? Colors.transparent : null,
body: _wasConnected
? SafeArea(
child: OrientationBuilder(
builder: (context, orientation) =>
orientation == Orientation.landscape
? PlayerScreenHorizontal()
: PlayerScreenVertical(),
),
)
: Center(child: CircularProgressIndicator()),
),
],
),
Widget widgetChild = Scaffold(
appBar: appBar,
backgroundColor: color,
body: SafeArea(child: child),
);
if (enabled)
widgetChild = Consumer<BackgroundProvider>(
builder: (context, provider, child) {
return _buildChild(context, provider, child!);
},
child: widgetChild,
);
if (appBar == null)
widgetChild = AnnotatedRegion<SystemUiOverlayStyle>(
value: getSystemUiOverlayStyle(context, enabled: enabled),
child: widgetChild,
);
return widgetChild;
}
}
@ -285,45 +320,8 @@ class _PlayerScreenVerticalState extends State<PlayerScreenVertical> {
height: 1000.w,
),
),
PlayerTextSubtext(textSize: 64.sp),
const SizedBox(height: 4.0),
Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
height: ScreenUtil().setSp(80),
child: audioHandler.mediaItem.value!.displayTitle!.length >=
26
? Marquee(
text: audioHandler.mediaItem.value!.displayTitle!,
style: TextStyle(
fontSize: ScreenUtil().setSp(64),
fontWeight: FontWeight.bold),
blankSpace: 32.0,
startPadding: 10.0,
accelerationDuration: Duration(seconds: 1),
pauseAfterRound: Duration(seconds: 2),
)
: Text(
audioHandler.mediaItem.value!.displayTitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: ScreenUtil().setSp(64),
fontWeight: FontWeight.bold),
)),
const SizedBox(height: 4),
Text(
audioHandler.mediaItem.value!.displaySubtitle ?? '',
maxLines: 1,
textAlign: TextAlign.center,
overflow: TextOverflow.clip,
style: TextStyle(
fontSize: ScreenUtil().setSp(52),
color: Theme.of(context).primaryColor,
),
),
],
),
SeekBar(),
PlaybackControls(86.sp),
Padding(
@ -335,6 +333,58 @@ class _PlayerScreenVerticalState extends State<PlayerScreenVertical> {
}
}
class PlayerTextSubtext extends StatelessWidget {
final double textSize;
const PlayerTextSubtext({Key? key, required this.textSize}) : super(key: key);
@override
Widget build(BuildContext context) {
return StreamBuilder<MediaItem?>(
stream: audioHandler.mediaItem,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox();
}
final currentMediaItem = snapshot.data!;
return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
SizedBox(
height: textSize * 1.5,
child: currentMediaItem.displayTitle!.length >= 26
? Marquee(
text: currentMediaItem.displayTitle!,
style: TextStyle(
fontSize: textSize, fontWeight: FontWeight.bold),
blankSpace: 32.0,
startPadding: 10.0,
accelerationDuration: Duration(seconds: 1),
pauseAfterRound: Duration(seconds: 2),
)
: Text(
currentMediaItem.displayTitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: textSize, fontWeight: FontWeight.bold),
)),
const SizedBox(height: 4),
Text(
currentMediaItem.displaySubtitle ?? '',
maxLines: 1,
textAlign: TextAlign.center,
overflow: TextOverflow.clip,
style: TextStyle(
fontSize: textSize * 0.8, // 20% smaller
color: Theme.of(context).primaryColor,
),
),
],
);
});
}
}
class QualityInfoWidget extends StatefulWidget {
@override
_QualityInfoWidgetState createState() => _QualityInfoWidgetState();
@ -433,32 +483,32 @@ class _RepeatButtonState extends State<RepeatButton> {
// ignore: missing_return
Icon get repeatIcon {
switch (playerHelper.repeatType) {
case LoopMode.off:
case AudioServiceRepeatMode.none:
return Icon(
Icons.repeat,
size: widget.iconSize,
semanticLabel: "Repeat off".i18n,
);
case LoopMode.all:
return Icon(
Icons.repeat,
color: Theme.of(context).primaryColor,
size: widget.iconSize,
semanticLabel: "Repeat".i18n,
);
case LoopMode.one:
case AudioServiceRepeatMode.one:
return Icon(
Icons.repeat_one,
color: Theme.of(context).primaryColor,
size: widget.iconSize,
semanticLabel: "Repeat one".i18n,
);
case AudioServiceRepeatMode.group:
case AudioServiceRepeatMode.all:
return Icon(
Icons.repeat,
semanticLabel: "Repeat".i18n,
);
}
}
@override
Widget build(BuildContext context) {
return IconButton(
color: playerHelper.repeatType == AudioServiceRepeatMode.none
? null
: Theme.of(context).primaryColor,
iconSize: widget.iconSize,
icon: repeatIcon,
onPressed: () async {
await playerHelper.changeRepeat();
@ -468,15 +518,38 @@ class _RepeatButtonState extends State<RepeatButton> {
}
}
class PlaybackControls extends StatefulWidget {
class ShuffleButton extends StatefulWidget {
final double iconSize;
PlaybackControls(this.iconSize, {Key? key}) : super(key: key);
const ShuffleButton({Key? key, required this.iconSize}) : super(key: key);
@override
_PlaybackControlsState createState() => _PlaybackControlsState();
_ShuffleButtonState createState() => _ShuffleButtonState();
}
class _PlaybackControlsState extends State<PlaybackControls> {
class _ShuffleButtonState extends State<ShuffleButton> {
@override
Widget build(BuildContext context) => IconButton(
icon: Icon(Icons.shuffle),
iconSize: widget.iconSize,
color:
playerHelper.shuffleEnabled ? Theme.of(context).primaryColor : null,
onPressed: _toggleShuffle,
);
void _toggleShuffle() {
playerHelper.toggleShuffle().then((_) => setState(() => null));
}
}
class FavoriteButton extends StatefulWidget {
final double size;
const FavoriteButton({Key? key, required this.size}) : super(key: key);
@override
_FavoriteButtonState createState() => _FavoriteButtonState();
}
class _FavoriteButtonState extends State<FavoriteButton> {
Icon get libraryIcon {
if (cache.checkTrackFavorite(
Track.fromMediaItem(audioHandler.mediaItem.value!))) {
@ -491,6 +564,34 @@ class _PlaybackControlsState extends State<PlaybackControls> {
);
}
@override
Widget build(BuildContext context) => IconButton(
icon: libraryIcon,
iconSize: widget.size,
onPressed: () async {
if (cache.libraryTracks == null) cache.libraryTracks = [];
if (cache.checkTrackFavorite(
Track.fromMediaItem(audioHandler.mediaItem.value!))) {
//Remove from library
setState(() =>
cache.libraryTracks!.remove(audioHandler.mediaItem.value!.id));
await deezerAPI.removeFavorite(audioHandler.mediaItem.value!.id);
await cache.save();
} else {
//Add
setState(() =>
cache.libraryTracks!.add(audioHandler.mediaItem.value!.id));
await deezerAPI.addFavoriteTrack(audioHandler.mediaItem.value!.id);
await cache.save();
}
},
);
}
class PlaybackControls extends StatelessWidget {
final double size;
PlaybackControls(this.size, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
@ -499,46 +600,28 @@ class _PlaybackControlsState extends State<PlaybackControls> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.max,
children: [
IconButton(
icon: Icon(
Icons.sentiment_very_dissatisfied,
semanticLabel: "Dislike".i18n,
),
iconSize: widget.iconSize * 0.75,
onPressed: () async {
await deezerAPI.dislikeTrack(audioHandler.mediaItem.value!.id);
if (playerHelper.queueIndex <
audioHandler.queue.value.length - 1) {
audioHandler.skipToNext();
}
}),
PrevNextButton(widget.iconSize, prev: true),
PlayPauseButton(widget.iconSize * 1.25),
PrevNextButton(widget.iconSize),
IconButton(
icon: libraryIcon,
iconSize: widget.iconSize * 0.75,
onPressed: () async {
if (cache.libraryTracks == null) cache.libraryTracks = [];
if (cache.checkTrackFavorite(
Track.fromMediaItem(audioHandler.mediaItem.value!))) {
//Remove from library
setState(() => cache.libraryTracks!
.remove(audioHandler.mediaItem.value!.id));
await deezerAPI
.removeFavorite(audioHandler.mediaItem.value!.id);
await cache.save();
} else {
//Add
setState(() =>
cache.libraryTracks!.add(audioHandler.mediaItem.value!.id));
await deezerAPI
.addFavoriteTrack(audioHandler.mediaItem.value!.id);
await cache.save();
}
},
)
ShuffleButton(iconSize: size * 0.75),
PrevNextButton(size, prev: true),
if (settings.enableFilledPlayButton)
Consumer<BackgroundProvider>(builder: (context, provider, _) {
final color = Theme.of(context).brightness == Brightness.light
? provider.dominantColor
: darken(provider.dominantColor);
return PlayPauseButton(size * 2.25,
filled: true,
color: color,
iconColor: Color.lerp(
(ThemeData.estimateBrightnessForColor(color) ==
Brightness.light
? Colors.black
: Colors.white),
color,
0.25));
})
else
PlayPauseButton(size * 1.25),
PrevNextButton(size),
RepeatButton(size * 0.75),
],
),
);
@ -551,18 +634,24 @@ class BigAlbumArt extends StatefulWidget {
}
class _BigAlbumArtState extends State<BigAlbumArt> {
PageController _pageController = PageController(
final _pageController = PageController(
initialPage: playerHelper.queueIndex,
viewportFraction: 1.0,
);
StreamSubscription? _currentItemSub;
bool _animationLock = true;
bool _animationLock = false;
bool _initiatedByUser = false;
@override
void initState() {
_currentItemSub = audioHandler.mediaItem.listen((event) async {
if (_initiatedByUser) {
_initiatedByUser = false;
return;
}
if (!_pageController.hasClients) return;
print('animating controller to page');
_animationLock = true;
// TODO: a lookup in the entire queue isn't that good, this can definitely be improved in some way
await _pageController.animateToPage(playerHelper.queueIndex,
duration: Duration(milliseconds: 300), curve: Curves.easeInOut);
_animationLock = false;
@ -589,39 +678,50 @@ class _BigAlbumArtState extends State<BigAlbumArt> {
PageRouteBuilder(
opaque: false, // transparent background
barrierDismissible: true,
pageBuilder: (context, _, __) {
return PhotoView(
imageProvider: CachedNetworkImageProvider(
audioHandler.mediaItem.value!.artUri.toString()),
maxScale: 8.0,
minScale: 0.2,
heroAttributes: PhotoViewHeroAttributes(
tag: audioHandler.mediaItem.value!.id),
backgroundDecoration:
BoxDecoration(color: Color.fromARGB(0x90, 0, 0, 0)));
pageBuilder: (context, animation, __) {
return FadeTransition(
opacity: animation,
child: PhotoView(
imageProvider: CachedNetworkImageProvider(
audioHandler.mediaItem.value!.artUri.toString()),
maxScale: 8.0,
minScale: 0.2,
heroAttributes: PhotoViewHeroAttributes(
tag: audioHandler.mediaItem.value!.id),
backgroundDecoration:
BoxDecoration(color: Color.fromARGB(0x90, 0, 0, 0))),
);
})),
child: PageView(
controller: _pageController,
onPageChanged: (int index) {
if (pageViewLock) {
pageViewLock = false;
return;
}
if (_animationLock) return;
audioHandler.skipToQueueItem(index);
},
children: List.generate(
audioHandler.queue.value.length,
(i) => Padding(
padding: const EdgeInsets.all(8.0),
child: Hero(
tag: audioHandler.queue.value[i].id,
child: CachedImage(
url: audioHandler.queue.value[i].artUri.toString(),
),
),
)),
),
child: StreamBuilder<List<MediaItem>>(
stream: audioHandler.queue,
initialData: audioHandler.queue.valueOrNull,
builder: (context, snapshot) {
if (!snapshot.hasData)
return const Center(child: CircularProgressIndicator());
final queue = snapshot.data!;
return PageView(
controller: _pageController,
onPageChanged: (int index) {
if (pageViewLock || _animationLock) return;
_initiatedByUser = true;
audioHandler.skipToQueueItem(index);
},
children: List.generate(
queue.length,
(i) => Padding(
padding: const EdgeInsets.all(8.0),
child: Hero(
tag: queue[i].id,
child: ClipRRect(
borderRadius: BorderRadius.circular(5.0),
child: CachedImage(
url: queue[i].artUri.toString(),
),
),
),
)),
);
}),
);
}
}
@ -664,7 +764,7 @@ class PlayerScreenTopRow extends StatelessWidget {
iconSize: this.iconSize ?? ScreenUtil().setSp(52),
splashRadius: this.iconSize ?? ScreenUtil().setWidth(52),
onPressed: () => Navigator.of(context)
.push(MaterialPageRoute(builder: (context) => QueueScreen())),
.pushRoute(builder: (context) => QueueScreen()),
),
],
);
@ -776,116 +876,6 @@ class _SeekBarState extends State<SeekBar> {
}
}
class QueueScreen extends StatefulWidget {
@override
_QueueScreenState createState() => _QueueScreenState();
}
class _QueueScreenState extends State<QueueScreen> {
late StreamSubscription _queueSub;
static const _dismissibleBackground = DecoratedBox(
decoration: BoxDecoration(color: Colors.red),
child: Align(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 24.0),
child: Icon(Icons.delete)),
alignment: Alignment.centerLeft));
static const _dismissibleSecondaryBackground = DecoratedBox(
decoration: BoxDecoration(color: Colors.red),
child: Align(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 24.0),
child: Icon(Icons.delete)),
alignment: Alignment.centerRight));
/// Basically a simple list that keeps itself synchronized with [AudioHandler.queue],
/// so that the [ReorderableListView] is updated instanly (as it should be)
List<MediaItem> _queueCache = [];
@override
void initState() {
_queueCache = audioHandler.queue.value;
_queueSub = audioHandler.queue.listen((newQueue) {
print('got queue $newQueue');
// avoid rebuilding if the cache has got the right update
if (listEquals(_queueCache, newQueue)) {
print('avoiding rebuilding queue since they are the same');
return;
}
setState(() => _queueCache = newQueue);
});
super.initState();
}
@override
void dispose() {
_queueSub.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: FreezerAppBar(
'Queue'.i18n,
systemUiOverlayStyle: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: Theme.of(context).scaffoldBackgroundColor,
systemNavigationBarDividerColor: Color(
Theme.of(context).scaffoldBackgroundColor.value - 0x00111111),
systemNavigationBarIconBrightness: Brightness.light,
),
actions: <Widget>[
IconButton(
icon: Icon(
Icons.shuffle,
semanticLabel: "Shuffle".i18n,
),
onPressed: () async {
await playerHelper.toggleShuffle();
setState(() {});
},
)
],
),
body: SafeArea(
child: ReorderableListView.builder(
onReorder: (int oldIndex, int newIndex) {
if (oldIndex == playerHelper.queueIndex) return;
setState(() => _queueCache.reorder(oldIndex, newIndex));
playerHelper.reorder(oldIndex, newIndex);
},
itemCount: _queueCache.length,
itemBuilder: (BuildContext context, int i) {
Track track = Track.fromMediaItem(audioHandler.queue.value[i]);
return Dismissible(
key: Key(track.id),
background: _dismissibleBackground,
secondaryBackground: _dismissibleSecondaryBackground,
onDismissed: (_) {
audioHandler.removeQueueItemAt(i);
setState(() => _queueCache.removeAt(i));
},
child: TrackTile(
track,
onTap: () {
pageViewLock = true;
audioHandler
.skipToQueueItem(i)
.then((value) => Navigator.of(context).pop());
},
key: Key(track.id),
),
);
},
),
),
);
}
}
class BottomBarControls extends StatelessWidget {
final double size;
const BottomBarControls({Key? key, required this.size}) : super(key: key);
@ -897,39 +887,56 @@ class BottomBarControls extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
IconButton(
icon: Icon(
Icons.subtitles,
size: size,
semanticLabel: "Lyrics".i18n,
),
onPressed: () async {
await Navigator.of(context)
.push(MaterialPageRoute(builder: (context) => LyricsScreen()));
updateColor();
},
),
icon: Icon(
Icons.subtitles,
size: size,
semanticLabel: "Lyrics".i18n,
),
onPressed: () => _pushLyrics(context)),
IconButton(
iconSize: size,
icon: Icon(
Icons.file_download,
semanticLabel: "Download".i18n,
),
onPressed: () async {
Track t = Track.fromMediaItem(audioHandler.mediaItem.value!);
if (await downloadManager.addOfflineTrack(t,
private: false, context: context, isSingleton: true) !=
false)
Fluttertoast.showToast(
msg: 'Downloads added!'.i18n,
gravity: ToastGravity.BOTTOM,
toastLength: Toast.LENGTH_SHORT);
},
),
icon: Icon(
Icons.sentiment_very_dissatisfied,
semanticLabel: "Dislike".i18n,
),
iconSize: size * 0.85,
onPressed: () async {
await deezerAPI.dislikeTrack(audioHandler.mediaItem.value!.id);
if (playerHelper.queueIndex <
audioHandler.queue.value.length - 1) {
audioHandler.skipToNext();
}
}),
// IconButton(
// iconSize: size,
// icon: Icon(
// Icons.file_download,
// semanticLabel: "Download".i18n,
// ),
// onPressed: () async {
// Track t = Track.fromMediaItem(audioHandler.mediaItem.value!);
// if (await downloadManager.addOfflineTrack(t,
// private: false, context: context, isSingleton: true) !=
// false)
// Fluttertoast.showToast(
// msg: 'Downloads added!'.i18n,
// gravity: ToastGravity.BOTTOM,
// toastLength: Toast.LENGTH_SHORT);
// },
// ),
QualityInfoWidget(),
RepeatButton(size),
FavoriteButton(size: size * 0.85),
PlayerMenuButton(size: size)
],
);
}
void _pushLyrics(BuildContext context) {
final builder = (ctx) => ChangeNotifierProvider<BackgroundProvider>.value(
value: Provider.of<BackgroundProvider>(context), child: LyricsScreen());
if (settings.playerBackgroundOnLyrics) {
Navigator.of(context).push(FadePageRoute(builder: builder));
return;
}
Navigator.of(context).pushRoute(builder: builder);
}
}

141
lib/ui/queue_screen.dart Normal file
View file

@ -0,0 +1,141 @@
import 'dart:async';
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/translations.i18n.dart';
import 'package:freezer/ui/elements.dart';
import 'package:freezer/ui/player_screen.dart';
import 'package:freezer/ui/tiles.dart';
class QueueScreen extends StatefulWidget {
@override
_QueueScreenState createState() => _QueueScreenState();
}
class _QueueScreenState extends State<QueueScreen> {
late StreamSubscription _queueSub;
static const _dismissibleBackground = DecoratedBox(
decoration: BoxDecoration(color: Colors.red),
child: Align(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 24.0),
child: Icon(Icons.delete)),
alignment: Alignment.centerLeft));
static const _dismissibleSecondaryBackground = DecoratedBox(
decoration: BoxDecoration(color: Colors.red),
child: Align(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 24.0),
child: Icon(Icons.delete)),
alignment: Alignment.centerRight));
/// Basically a simple list that keeps itself synchronized with [AudioHandler.queue],
/// so that the [ReorderableListView] is updated instanly (as it should be)
List<MediaItem> _queueCache = [];
@override
void initState() {
_queueCache = List.from(audioHandler.queue.value); // avoid shadow-copying
_queueSub = audioHandler.queue.listen((newQueue) {
print('got new queue!');
// avoid rebuilding if the cache has got the right update
// if (listEquals(_queueCache, newQueue)) {
// print('avoiding rebuilding queue since they are the same');
// return;
// }
setState(() => _queueCache = List.from(newQueue));
});
super.initState();
}
@override
void dispose() {
_queueSub.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: FreezerAppBar(
'Queue'.i18n,
systemUiOverlayStyle: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: Theme.of(context).scaffoldBackgroundColor,
systemNavigationBarDividerColor: Color(
Theme.of(context).scaffoldBackgroundColor.value - 0x00111111),
systemNavigationBarIconBrightness: Brightness.light,
),
// actions: <Widget>[
// IconButton(
// icon: Icon(
// Icons.shuffle,
// semanticLabel: "Shuffle".i18n,
// ),
// onPressed: () async {
// await playerHelper.toggleShuffle();
// setState(() {});
// },
// )
// ],
),
body: SafeArea(
child: ReorderableListView.builder(
onReorder: (int oldIndex, int newIndex) {
if (oldIndex == playerHelper.queueIndex) return;
setState(() => _queueCache..reorder(oldIndex, newIndex));
playerHelper.reorder(oldIndex, newIndex);
},
itemCount: _queueCache.length,
itemBuilder: (BuildContext context, int i) {
Track track = Track.fromMediaItem(audioHandler.queue.value[i]);
return Dismissible(
key: ValueKey<String>(track.id),
background: _dismissibleBackground,
secondaryBackground: _dismissibleSecondaryBackground,
onDismissed: (_) {
audioHandler.removeQueueItemAt(i);
setState(() => _queueCache.removeAt(i));
},
confirmDismiss: (_) {
if (i == playerHelper.queueIndex)
return audioHandler.skipToNext().then((value) => true);
return Future.value(true);
// final completer = Completer<bool>();
// ScaffoldMessenger.of(context).clearSnackBars();
// ScaffoldMessenger.of(context)
// .showSnackBar(SnackBar(
// behavior: SnackBarBehavior.floating,
// content: Text('Song deleted from queue'),
// action: SnackBarAction(
// label: 'UNDO',
// onPressed: () => completer.complete(false))))
// .closed
// .then((value) {
// if (value == SnackBarClosedReason.action) return;
// completer.complete(true);
// });
// return completer.future;
},
child: TrackTile(
track,
onTap: () {
pageViewLock = true;
audioHandler.skipToQueueItem(i).then((value) {
Navigator.of(context).pop();
pageViewLock = false;
});
},
key: Key(track.id),
),
);
},
),
),
);
}
}

View file

@ -75,11 +75,11 @@ class _SearchScreenState extends State<SearchScreen> {
return;
}
Navigator.of(context).push(MaterialPageRoute(
Navigator.of(context).pushRoute(
builder: (context) => SearchResultsScreen(
_query,
offline: _offline,
)));
));
}
@override
@ -253,14 +253,14 @@ class _SearchScreenState extends State<SearchScreen> {
color: Color(0xff7c42bb),
text: 'Shows'.i18n,
icon: Icon(FontAwesome5.podcast),
onTap: () => Navigator.of(context).push(MaterialPageRoute(
onTap: () => Navigator.of(context).pushRoute(
builder: (context) => Scaffold(
appBar: FreezerAppBar('Shows'.i18n),
body: SingleChildScrollView(
child: HomePageScreen(
channel: DeezerChannel(target: 'shows'))),
),
)),
),
)
],
),
@ -272,7 +272,7 @@ class _SearchScreenState extends State<SearchScreen> {
color: Color(0xffff555d),
icon: Icon(FontAwesome5.chart_line),
text: 'Charts'.i18n,
onTap: () => Navigator.of(context).push(MaterialPageRoute(
onTap: () => Navigator.of(context).pushRoute(
builder: (context) => Scaffold(
appBar: FreezerAppBar('Charts'.i18n),
body: SingleChildScrollView(
@ -280,13 +280,13 @@ class _SearchScreenState extends State<SearchScreen> {
channel:
DeezerChannel(target: 'channels/charts'))),
),
)),
),
),
SearchBrowseCard(
color: Color(0xff2c4ea7),
text: 'Browse'.i18n,
icon: Image.asset('assets/browse_icon.png', width: 26.0),
onTap: () => Navigator.of(context).push(MaterialPageRoute(
onTap: () => Navigator.of(context).pushRoute(
builder: (context) => Scaffold(
appBar: FreezerAppBar('Browse'.i18n),
body: SingleChildScrollView(
@ -294,7 +294,7 @@ class _SearchScreenState extends State<SearchScreen> {
channel:
DeezerChannel(target: 'channels/explore'))),
),
)),
),
)
],
)
@ -337,8 +337,8 @@ class _SearchScreenState extends State<SearchScreen> {
return AlbumTile(
data,
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => AlbumDetails(data)));
Navigator.of(context)
.pushRoute(builder: (context) => AlbumDetails(data));
},
onHold: () {
MenuSheet m = MenuSheet(context);
@ -350,8 +350,8 @@ class _SearchScreenState extends State<SearchScreen> {
return ArtistHorizontalTile(
data,
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => ArtistDetails(data)));
Navigator.of(context)
.pushRoute(builder: (context) => ArtistDetails(data));
},
onHold: () {
MenuSheet m = MenuSheet(context);
@ -363,8 +363,8 @@ class _SearchScreenState extends State<SearchScreen> {
return PlaylistTile(
data,
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => PlaylistDetails(data)));
Navigator.of(context).pushRoute(
builder: (context) => PlaylistDetails(data));
},
onHold: () {
MenuSheet m = MenuSheet(context);
@ -535,13 +535,13 @@ class SearchResultsScreen extends StatelessWidget {
ListTile(
title: Text('Show all tracks'.i18n),
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
Navigator.of(context).pushRoute(
builder: (context) => TrackListScreen(
results.tracks,
QueueSource(
id: query,
source: 'search',
text: 'Search'.i18n))));
text: 'Search'.i18n)));
},
),
FreezerDivider()
@ -577,16 +577,16 @@ class SearchResultsScreen extends StatelessWidget {
},
onTap: () {
cache.addToSearchHistory(a);
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => AlbumDetails(a)));
Navigator.of(context)
.pushRoute(builder: (context) => AlbumDetails(a));
},
);
}),
ListTile(
title: Text('Show all albums'.i18n),
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => AlbumListScreen(results.albums)));
Navigator.of(context).pushRoute(
builder: (context) => AlbumListScreen(results.albums));
},
),
FreezerDivider()
@ -617,8 +617,8 @@ class SearchResultsScreen extends StatelessWidget {
a,
onTap: () {
cache.addToSearchHistory(a);
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => ArtistDetails(a)));
Navigator.of(context).pushRoute(
builder: (context) => ArtistDetails(a));
},
onHold: () {
MenuSheet m = MenuSheet(context);
@ -656,8 +656,8 @@ class SearchResultsScreen extends StatelessWidget {
p,
onTap: () {
cache.addToSearchHistory(p);
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => PlaylistDetails(p)));
Navigator.of(context)
.pushRoute(builder: (context) => PlaylistDetails(p));
},
onHold: () {
MenuSheet m = MenuSheet(context);
@ -668,9 +668,9 @@ class SearchResultsScreen extends StatelessWidget {
ListTile(
title: Text('Show all playlists'.i18n),
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
Navigator.of(context).pushRoute(
builder: (context) =>
SearchResultPlaylists(results.playlists)));
SearchResultPlaylists(results.playlists));
},
),
FreezerDivider()
@ -701,16 +701,16 @@ class SearchResultsScreen extends StatelessWidget {
return ShowTile(
s,
onTap: () async {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => ShowScreen(s)));
Navigator.of(context)
.pushRoute(builder: (context) => ShowScreen(s));
},
);
}),
ListTile(
title: Text('Show all shows'.i18n),
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => ShowListScreen(results.shows)));
Navigator.of(context).pushRoute(
builder: (context) => ShowListScreen(results.shows));
},
),
FreezerDivider()
@ -762,9 +762,9 @@ class SearchResultsScreen extends StatelessWidget {
ListTile(
title: Text('Show all episodes'.i18n),
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
Navigator.of(context).pushRoute(
builder: (context) =>
EpisodeListScreen(results.episodes)));
EpisodeListScreen(results.episodes));
})
];
}
@ -816,15 +816,15 @@ class TrackListScreen extends StatelessWidget {
body: ListView.builder(
itemCount: tracks!.length,
itemBuilder: (BuildContext context, int i) {
Track? t = tracks![i];
Track t = tracks![i]!;
return TrackTile(
t,
onTap: () {
playerHelper.playFromTrackList(tracks!, t!.id, queueSource);
playerHelper.playFromTrackList(tracks!, t.id, queueSource);
},
onHold: () {
MenuSheet m = MenuSheet(context);
m.defaultTrackMenu(t!);
m.defaultTrackMenu(t);
},
);
},
@ -849,8 +849,8 @@ class AlbumListScreen extends StatelessWidget {
return AlbumTile(
a,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => AlbumDetails(a)));
Navigator.of(context)
.pushRoute(builder: (context) => AlbumDetails(a));
},
onHold: () {
MenuSheet m = MenuSheet(context);
@ -878,8 +878,8 @@ class SearchResultPlaylists extends StatelessWidget {
return PlaylistTile(
p,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => PlaylistDetails(p)));
Navigator.of(context)
.pushRoute(builder: (context) => PlaylistDetails(p));
},
onHold: () {
MenuSheet m = MenuSheet(context);

View file

@ -7,6 +7,7 @@ import 'package:flutter_material_color_picker/flutter_material_color_picker.dart
import 'package:fluttericon/font_awesome5_icons.dart';
import 'package:fluttericon/web_symbols_icons.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezer/api/definitions.dart';
import 'package:package_info/package_info.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:scrobblenaut/scrobblenaut.dart';
@ -39,33 +40,33 @@ class _SettingsScreenState extends State<SettingsScreen> {
ListTile(
title: Text('General'.i18n),
leading: LeadingIcon(Icons.settings, color: Color(0xffeca704)),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (context) => GeneralSettings())),
onTap: () => Navigator.of(context)
.pushRoute(builder: (context) => GeneralSettings()),
),
ListTile(
title: Text('Download Settings'.i18n),
leading:
LeadingIcon(Icons.cloud_download, color: Color(0xffbe3266)),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (context) => DownloadsSettings())),
onTap: () => Navigator.of(context)
.pushRoute(builder: (context) => DownloadsSettings()),
),
ListTile(
title: Text('Appearance'.i18n),
leading: LeadingIcon(Icons.color_lens, color: Color(0xff4b2e7e)),
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (context) => AppearanceSettings())),
onTap: () => Navigator.of(context)
.pushRoute(builder: (context) => AppearanceSettings()),
),
ListTile(
title: Text('Quality'.i18n),
leading: LeadingIcon(Icons.high_quality, color: Color(0xff384697)),
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (context) => QualitySettings())),
onTap: () => Navigator.of(context)
.pushRoute(builder: (context) => QualitySettings()),
),
ListTile(
title: Text('Deezer'.i18n),
leading: LeadingIcon(Icons.equalizer, color: Color(0xff0880b5)),
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (context) => DeezerSettings())),
onTap: () => Navigator.of(context)
.pushRoute(builder: (context) => DeezerSettings()),
),
//Language select
ListTile(
@ -111,14 +112,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
ListTile(
title: Text('Updates'.i18n),
leading: LeadingIcon(Icons.update, color: Color(0xff2ba766)),
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (context) => UpdaterScreen())),
onTap: () => Navigator.of(context)
.pushRoute(builder: (context) => UpdaterScreen()),
),
ListTile(
title: Text('About'.i18n),
leading: LeadingIcon(Icons.info, color: Colors.grey),
onTap: () => Navigator.push(context,
MaterialPageRoute(builder: (context) => CreditsScreen())),
onTap: () => Navigator.of(context)
.pushRoute(builder: (context) => CreditsScreen()),
),
],
),
@ -143,7 +144,7 @@ class _AppearanceSettingsState extends State<AppearanceSettings> {
ListTile(
title: Text('Theme'.i18n),
subtitle: Text('Currently'.i18n +
': ${settings.theme.toString().split('.').last}'),
': ${settings.theme.toString().split('.').lastItem}'),
leading: Icon(Icons.color_lens),
onTap: () {
showDialog(
@ -195,7 +196,7 @@ class _AppearanceSettingsState extends State<AppearanceSettings> {
),
SwitchListTile(
title: Text('Use system theme'.i18n),
value: settings.useSystemTheme!,
value: settings.useSystemTheme,
onChanged: (bool v) async {
settings.useSystemTheme = v;
@ -206,7 +207,7 @@ class _AppearanceSettingsState extends State<AppearanceSettings> {
ListTile(
title: Text('Font'.i18n),
leading: Icon(Icons.font_download),
subtitle: Text(settings.font!),
subtitle: Text(settings.font),
onTap: () {
showDialog(
context: context,
@ -217,7 +218,7 @@ class _AppearanceSettingsState extends State<AppearanceSettings> {
SwitchListTile(
title: Text('Player gradient background'.i18n),
secondary: Icon(Icons.colorize),
value: settings.colorGradientBackground!,
value: settings.colorGradientBackground,
onChanged: (bool v) async {
setState(() => settings.colorGradientBackground = v);
await settings.save();
@ -227,19 +228,97 @@ class _AppearanceSettingsState extends State<AppearanceSettings> {
title: Text('Blur player background'.i18n),
subtitle: Text('Might have impact on performance'.i18n),
secondary: Icon(Icons.blur_on),
value: settings.blurPlayerBackground!,
value: settings.blurPlayerBackground,
onChanged: (bool v) async {
setState(() => settings.blurPlayerBackground = v);
await settings.save();
},
),
SwitchListTile(
title: Text('Use player background on lyrics page'),
value: settings.playerBackgroundOnLyrics,
secondary: Icon(Icons.wallpaper),
onChanged: settings.blurPlayerBackground ||
settings.colorGradientBackground
? (bool v) {
setState(() => settings.playerBackgroundOnLyrics = v);
settings.save();
}
: null),
ListTile(
title: Text('Screens style'),
subtitle:
Text('Style of the transition between screens within the app'),
leading: Icon(Icons.auto_awesome_motion),
onTap: () => showDialog(
context: context,
builder: (context) {
return SimpleDialog(
title: Text('Select screens style'),
children: <Widget>[
SimpleDialogOption(
child: Text('Blur slide (might be laggy!)'),
onPressed: () {
settings.navigatorRouteType =
NavigatorRouteType.blur_slide;
settings.save();
Navigator.of(context).pop();
},
),
SimpleDialogOption(
child: Text('Fade'),
onPressed: () {
settings.navigatorRouteType = NavigatorRouteType.fade;
settings.save();
Navigator.of(context).pop();
},
),
SimpleDialogOption(
child: Text('Fade with blur (might be laggy!)'),
onPressed: () {
settings.navigatorRouteType =
NavigatorRouteType.fade_blur;
settings.save();
Navigator.of(context).pop();
},
),
SimpleDialogOption(
child: Text('Material (default)'),
onPressed: () {
settings.navigatorRouteType =
NavigatorRouteType.material;
settings.save();
Navigator.of(context).pop();
},
),
SimpleDialogOption(
child: Text('Cupertino (iOS)'),
onPressed: () {
settings.navigatorRouteType =
NavigatorRouteType.cupertino;
settings.save();
Navigator.of(context).pop();
},
),
],
);
}),
),
SwitchListTile(
title: Text('Enable filled play button'),
secondary: Icon(Icons.play_circle),
value: settings.enableFilledPlayButton,
onChanged: (bool v) {
setState(() => settings.enableFilledPlayButton = v);
settings.save();
}),
SwitchListTile(
title: Text('Visualizer'.i18n),
subtitle: Text(
'Show visualizers on lyrics page. WARNING: Requires microphone permission!'
.i18n),
secondary: Icon(Icons.equalizer),
value: settings.lyricsVisualizer!,
value: settings.lyricsVisualizer,
onChanged: null, // TODO: visualizer
//(bool v) async {
// if (await Permission.microphone.request().isGranted) {
@ -454,7 +533,7 @@ class QualityPicker extends StatefulWidget {
}
class _QualityPickerState extends State<QualityPicker> {
AudioQuality? _quality;
late AudioQuality _quality;
@override
void initState() {
@ -481,7 +560,7 @@ class _QualityPickerState extends State<QualityPicker> {
}
//Update quality in settings
void _updateQuality(AudioQuality? q) async {
void _updateQuality(AudioQuality q) async {
setState(() {
_quality = q;
});
@ -786,7 +865,7 @@ class DownloadsSettings extends StatefulWidget {
}
class _DownloadsSettingsState extends State<DownloadsSettings> {
double _downloadThreads = settings.downloadThreads!.toDouble();
double _downloadThreads = settings.downloadThreads.toDouble();
TextEditingController _artistSeparatorController =
TextEditingController(text: settings.artistSeparator);
@ -809,14 +888,14 @@ class _DownloadsSettingsState extends State<DownloadsSettings> {
settings.save();
});
//Navigate
// Navigator.of(context).push(MaterialPageRoute(
// Navigator.of(context).pushRoute(
// builder: (context) => DirectoryPicker(
// settings.downloadPath,
// onSelect: (String p) async {
// setState(() => settings.downloadPath = p);
// await settings.save();
// },
// )));
// ));
},
),
ListTile(
@ -871,7 +950,7 @@ class _DownloadsSettingsState extends State<DownloadsSettings> {
_downloadThreads = val;
setState(() {
settings.downloadThreads = _downloadThreads.round();
_downloadThreads = settings.downloadThreads!.toDouble();
_downloadThreads = settings.downloadThreads.toDouble();
});
await settings.save();
@ -902,12 +981,12 @@ class _DownloadsSettingsState extends State<DownloadsSettings> {
ListTile(
title: Text('Tags'.i18n),
leading: Icon(Icons.label),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (context) => TagSelectionScreen())),
onTap: () => Navigator.of(context)
.pushRoute(builder: (context) => TagSelectionScreen()),
),
SwitchListTile(
title: Text('Create folders for artist'.i18n),
value: settings.artistFolder!,
value: settings.artistFolder,
onChanged: (v) {
setState(() => settings.artistFolder = v);
settings.save();
@ -916,7 +995,7 @@ class _DownloadsSettingsState extends State<DownloadsSettings> {
),
SwitchListTile(
title: Text('Create folders for albums'.i18n),
value: settings.albumFolder!,
value: settings.albumFolder,
onChanged: (v) {
setState(() => settings.albumFolder = v);
settings.save();
@ -924,7 +1003,7 @@ class _DownloadsSettingsState extends State<DownloadsSettings> {
secondary: Icon(Icons.folder)),
SwitchListTile(
title: Text('Create folder for playlist'.i18n),
value: settings.playlistFolder!,
value: settings.playlistFolder,
onChanged: (v) {
setState(() => settings.playlistFolder = v);
settings.save();
@ -933,7 +1012,7 @@ class _DownloadsSettingsState extends State<DownloadsSettings> {
FreezerDivider(),
SwitchListTile(
title: Text('Separate albums by discs'.i18n),
value: settings.albumDiscFolder!,
value: settings.albumDiscFolder,
onChanged: (v) {
setState(() => settings.albumDiscFolder = v);
settings.save();
@ -941,7 +1020,7 @@ class _DownloadsSettingsState extends State<DownloadsSettings> {
secondary: Icon(Icons.album)),
SwitchListTile(
title: Text('Overwrite already downloaded files'.i18n),
value: settings.overwriteDownload!,
value: settings.overwriteDownload,
onChanged: (v) {
setState(() => settings.overwriteDownload = v);
settings.save();
@ -949,7 +1028,7 @@ class _DownloadsSettingsState extends State<DownloadsSettings> {
secondary: Icon(Icons.delete)),
SwitchListTile(
title: Text('Download .LRC lyrics'.i18n),
value: settings.downloadLyrics!,
value: settings.downloadLyrics,
onChanged: (v) {
setState(() => settings.downloadLyrics = v);
settings.save();
@ -958,7 +1037,7 @@ class _DownloadsSettingsState extends State<DownloadsSettings> {
FreezerDivider(),
SwitchListTile(
title: Text('Save cover file for every track'.i18n),
value: settings.trackCover!,
value: settings.trackCover,
onChanged: (v) {
setState(() => settings.trackCover = v);
settings.save();
@ -966,7 +1045,7 @@ class _DownloadsSettingsState extends State<DownloadsSettings> {
secondary: Icon(Icons.image)),
SwitchListTile(
title: Text('Save album cover'.i18n),
value: settings.albumCover!,
value: settings.albumCover,
onChanged: (v) {
setState(() => settings.albumCover = v);
settings.save();
@ -990,6 +1069,7 @@ class _DownloadsSettingsState extends State<DownloadsSettings> {
))
.toList(),
onChanged: (int? n) async {
if (n == null) return;
setState(() {
settings.albumArtResolution = n;
});
@ -1000,7 +1080,7 @@ class _DownloadsSettingsState extends State<DownloadsSettings> {
title: Text('Create .nomedia files'.i18n),
subtitle:
Text('To prevent gallery being filled with album art'.i18n),
value: settings.nomediaFiles!,
value: settings.nomediaFiles,
onChanged: (v) {
setState(() => settings.nomediaFiles = v);
settings.save();
@ -1024,8 +1104,8 @@ class _DownloadsSettingsState extends State<DownloadsSettings> {
ListTile(
title: Text('Download Log'.i18n),
leading: Icon(Icons.sticky_note_2),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (context) => DownloadLogViewer())),
onTap: () => Navigator.of(context)
.pushRoute(builder: (context) => DownloadLogViewer()),
)
],
),
@ -1074,13 +1154,13 @@ class _TagSelectionScreenState extends State<TagSelectionScreen> {
(i) => ListTile(
title: Text(tags[i].title),
leading: Switch(
value: settings.tags!.contains(tags[i].value),
value: settings.tags.contains(tags[i].value),
onChanged: (v) async {
//Update
if (v)
settings.tags!.add(tags[i].value);
settings.tags.add(tags[i].value);
else
settings.tags!.remove(tags[i].value);
settings.tags.remove(tags[i].value);
setState(() {});
await settings.save();
},
@ -1116,7 +1196,7 @@ class _GeneralSettingsState extends State<GeneralSettings> {
showDialog(
context: context,
builder: (context) {
deezerAPI.authorize()!.then((v) {
deezerAPI.authorize().then((v) {
if (v) {
setState(() => settings.offlineMode = false);
} else {
@ -1131,11 +1211,8 @@ class _GeneralSettingsState extends State<GeneralSettings> {
});
return AlertDialog(
title: Text('Logging in...'.i18n),
content: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[CircularProgressIndicator()],
));
content:
const Center(child: CircularProgressIndicator()));
});
},
),
@ -1145,7 +1222,7 @@ class _GeneralSettingsState extends State<GeneralSettings> {
'Might enable some equalizer apps to work. Requires restart of Freezer'
.i18n),
secondary: Icon(Icons.equalizer),
value: settings.enableEqualizer!,
value: settings.enableEqualizer,
onChanged: (v) async {
setState(() => settings.enableEqualizer = v);
settings.save();
@ -1155,7 +1232,7 @@ class _GeneralSettingsState extends State<GeneralSettings> {
title: Text('Ignore interruptions'.i18n),
subtitle: Text('Requires app restart to apply!'.i18n),
secondary: Icon(Icons.not_interested),
value: settings.ignoreInterruptions!,
value: settings.ignoreInterruptions,
onChanged: (bool v) async {
setState(() => settings.ignoreInterruptions = v);
await settings.save();

View file

@ -12,7 +12,7 @@ import 'cached_image.dart';
import 'dart:async';
class TrackTile extends StatefulWidget {
final Track? track;
final Track track;
final void Function()? onTap;
final void Function()? onHold;
final Widget? trailing;
@ -25,7 +25,7 @@ class TrackTile extends StatefulWidget {
}
class _TrackTileState extends State<TrackTile> {
StreamSubscription? _subscription;
late StreamSubscription _subscription;
bool _isOffline = false;
bool _isHighlighted = false;
@ -34,7 +34,7 @@ class _TrackTileState extends State<TrackTile> {
//Listen to media item changes, update text color if currently playing
_subscription = audioHandler.mediaItem.listen((mediaItem) {
if (mediaItem == null) return;
if (mediaItem.id == widget.track?.id)
if (mediaItem.id == widget.track.id && !_isHighlighted)
setState(() => _isHighlighted = true);
else if (_isHighlighted) setState(() => _isHighlighted = false);
});
@ -48,7 +48,7 @@ class _TrackTileState extends State<TrackTile> {
@override
void dispose() {
_subscription?.cancel();
_subscription.cancel();
super.dispose();
}
@ -56,18 +56,18 @@ class _TrackTileState extends State<TrackTile> {
Widget build(BuildContext context) {
return ListTile(
title: Text(
widget.track!.title!,
widget.track.title!,
maxLines: 1,
overflow: TextOverflow.clip,
style: TextStyle(
color: _isHighlighted ? Theme.of(context).primaryColor : null),
),
subtitle: Text(
widget.track!.artistString,
widget.track.artistString,
maxLines: 1,
),
leading: CachedImage(
url: widget.track!.albumArt!.thumb!,
url: widget.track.albumArt!.thumb!,
width: 48,
),
onTap: widget.onTap,
@ -84,7 +84,7 @@ class _TrackTileState extends State<TrackTile> {
size: 12.0,
),
),
if (widget.track!.explicit ?? false)
if (widget.track.explicit ?? false)
Padding(
padding: EdgeInsets.symmetric(horizontal: 2.0),
child: Text(
@ -95,7 +95,7 @@ class _TrackTileState extends State<TrackTile> {
Container(
width: 42.0,
child: Text(
widget.track!.durationString,
widget.track.durationString,
textAlign: TextAlign.center,
),
),
@ -108,8 +108,8 @@ class _TrackTileState extends State<TrackTile> {
class AlbumTile extends StatelessWidget {
final Album? album;
final Function? onTap;
final Function? onHold;
final void Function()? onTap;
final void Function()? onHold;
final Widget? trailing;
AlbumTile(this.album, {this.onTap, this.onHold, this.trailing});
@ -129,8 +129,8 @@ class AlbumTile extends StatelessWidget {
url: album!.art!.thumb,
width: 48,
),
onTap: onTap as void Function()?,
onLongPress: onHold as void Function()?,
onTap: onTap,
onLongPress: onHold,
trailing: trailing,
);
}
@ -138,8 +138,8 @@ class AlbumTile extends StatelessWidget {
class ArtistTile extends StatelessWidget {
final Artist? artist;
final Function? onTap;
final Function? onHold;
final void Function()? onTap;
final void Function()? onHold;
ArtistTile(this.artist, {this.onTap, this.onHold});
@ -147,45 +147,33 @@ class ArtistTile extends StatelessWidget {
Widget build(BuildContext context) {
return SizedBox(
width: 150,
child: Container(
child: InkWell(
onTap: onTap as void Function()?,
onLongPress: onHold as void Function()?,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
height: 4,
),
CachedImage(
url: artist!.picture!.thumb,
circular: true,
width: 100,
),
Container(
height: 8,
),
Text(
artist!.name!,
maxLines: 1,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 14.0),
),
Container(
height: 4,
),
],
),
),
));
child: InkWell(
onTap: onTap,
onLongPress: onHold,
child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
const SizedBox(height: 4),
CachedImage(
url: artist!.picture!.thumb,
circular: true,
width: 100,
),
const SizedBox(height: 8),
Text(
artist!.name!,
maxLines: 1,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14.0),
),
const SizedBox(height: 4),
])));
}
}
class PlaylistTile extends StatelessWidget {
final Playlist? playlist;
final Function? onTap;
final Function? onHold;
final void Function()? onTap;
final void Function()? onHold;
final Widget? trailing;
PlaylistTile(this.playlist, {this.onHold, this.onTap, this.trailing});
@ -216,8 +204,8 @@ class PlaylistTile extends StatelessWidget {
url: playlist!.image!.thumb,
width: 48,
),
onTap: onTap as void Function()?,
onLongPress: onHold as void Function()?,
onTap: onTap,
onLongPress: onHold,
trailing: trailing,
);
}
@ -225,8 +213,8 @@ class PlaylistTile extends StatelessWidget {
class ArtistHorizontalTile extends StatelessWidget {
final Artist? artist;
final Function? onTap;
final Function? onHold;
final void Function()? onTap;
final void Function()? onHold;
final Widget? trailing;
ArtistHorizontalTile(this.artist, {this.onHold, this.onTap, this.trailing});
@ -244,8 +232,8 @@ class ArtistHorizontalTile extends StatelessWidget {
url: artist!.picture!.thumb,
circular: true,
),
onTap: onTap as void Function()?,
onLongPress: onHold as void Function()?,
onTap: onTap,
onLongPress: onHold,
trailing: trailing,
),
);

View file

@ -608,6 +608,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
nested:
dependency: transitive
description:
name: nested
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
numberpicker:
dependency: "direct main"
description:
@ -762,6 +769,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "4.2.3"
provider:
dependency: "direct main"
description:
name: provider
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.0"
pub_semver:
dependency: transitive
description:

View file

@ -82,6 +82,7 @@ dependencies:
url: https://github.com/ryanheise/just_audio.git
ref: dev
path: just_audio/
provider: ^6.0.0
dependency_overrides:
analyzer: ^2.0.0