From c792daea1998315b5478e29ec49e895b94805de2 Mon Sep 17 00:00:00 2001 From: pato05 Date: Mon, 1 Nov 2021 17:41:25 +0100 Subject: [PATCH] pre-new design --- lib/api/deezer.dart | 9 +- lib/api/definitions.dart | 108 +++- lib/api/download.dart | 13 +- lib/api/player.dart | 327 ++++++----- lib/main.dart | 36 +- lib/page_routes/basic_page_route.dart | 20 + lib/page_routes/blur_slide.dart | 43 ++ lib/page_routes/fade.dart | 41 ++ lib/settings.dart | 76 +-- lib/settings.g.dart | 16 + lib/ui/animated_blur.dart | 28 + lib/ui/cached_image.dart | 57 +- lib/ui/details_screens.dart | 31 +- lib/ui/downloads_screen.dart | 11 +- lib/ui/elements.dart | 56 +- lib/ui/home_screen.dart | 26 +- lib/ui/library.dart | 85 ++- lib/ui/login_screen.dart | 4 +- lib/ui/lyrics.dart | 257 --------- lib/ui/lyrics_screen.dart | 248 ++++++++ lib/ui/player_bar.dart | 200 ++++--- lib/ui/player_screen.dart | 791 +++++++++++++------------- lib/ui/queue_screen.dart | 141 +++++ lib/ui/search.dart | 78 +-- lib/ui/settings_screen.dart | 177 ++++-- lib/ui/tiles.dart | 98 ++-- pubspec.lock | 14 + pubspec.yaml | 1 + 28 files changed, 1772 insertions(+), 1220 deletions(-) create mode 100644 lib/page_routes/basic_page_route.dart create mode 100644 lib/page_routes/blur_slide.dart create mode 100644 lib/page_routes/fade.dart create mode 100644 lib/ui/animated_blur.dart delete mode 100644 lib/ui/lyrics.dart create mode 100644 lib/ui/lyrics_screen.dart create mode 100644 lib/ui/queue_screen.dart diff --git a/lib/api/deezer.dart b/lib/api/deezer.dart index 5911092..aaea253 100644 --- a/lib/api/deezer.dart +++ b/lib/api/deezer.dart @@ -19,7 +19,7 @@ class DeezerAPI { String? favoritesPlaylistId; String? sid; - Future? _authorizing; + Future? _authorizing; //Get headers Map 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 authorize() async => this._authorizing ??= this.rawAuthorize(); //Login with email static Future getArlByEmail(String? email, String password) async { diff --git a/lib/api/definitions.dart b/lib/api/definitions.dart index ae764ae..ea2b31e 100644 --- a/lib/api/definitions.dart +++ b/lib/api/definitions.dart @@ -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 toJson() { - String type = this.type.toString().split('.').last; + String type = describeEnum(this.type!); return {'type': type, 'value': value.toJson()}; } } @@ -1077,17 +1083,95 @@ Map mediaItemToJson(MediaItem mi) => { 'displayDescription': mi.displayDescription, }; MediaItem mediaItemFromJson(Map 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, - album: json['album'], - artist: json['artist'], - displayTitle: json['displayTitle'], - displaySubtitle: json['displaySubtitle'], - displayDescription: json['displayDescription'], + extras: json['extras'] as Map?, + 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 on List { + 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 pushRoute({required WidgetBuilder builder}) { + final PageRoute route; + switch (settings.navigatorRouteType) { + case NavigatorRouteType.blur_slide: + route = BlurSlidePageRoute(builder: builder); + break; + case NavigatorRouteType.material: + route = MaterialPageRoute(builder: builder); + break; + case NavigatorRouteType.cupertino: + route = CupertinoPageRoute(builder: builder); + break; + case NavigatorRouteType.fade: + route = FadePageRoute(builder: builder); + break; + case NavigatorRouteType.fade_blur: + route = FadePageRoute(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, +} diff --git a/lib/api/download.dart b/lib/api/download.dart index ad33e49..00d9da8 100644 --- a/lib/api/download.dart +++ b/lib/api/download.dart @@ -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> getDownloads() async { - List raw = await (platform.invokeMethod('getDownloads') - as FutureOr>); + 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 { diff --git a/lib/api/player.dart b/lib/api/player.dart index 6ccbce7..db4a9c7 100644 --- a/lib/api/player.dart +++ b/lib/api/player.dart @@ -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 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 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 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? tracks = []; + List? 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 mi = tracks.map((t) => t.toMediaItem()).toList(); @@ -231,8 +266,7 @@ class PlayerHelper { //Play mix by track Future playMix(String trackId, String trackTitle) async { - List tracks = - await (deezerAPI.playMix(trackId) as FutureOr>); + List tracks = (await deezerAPI.playMix(trackId))!; playFromTrackList( tracks, tracks[0].id, @@ -337,9 +371,7 @@ class AudioPlayerTask extends BaseAudioHandler { late AudioPlayer _player; //Queue - List? _queue = []; List? _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>? _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? 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?); } //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 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 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 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.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 q) async { _lastPosition = null; //just_audio - _shuffle = false; _originalQueue = null; _player.stop(); if (_isInitialized) _audioSource.clear(); //Filter duplicate IDs List 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 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? 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 _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>(mediaItemToJson).toList(), + 'queue': queue.value.map>(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 json = jsonDecode(await f.readAsString()); - this._queue = - (json['queue'] ?? []).map(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? _queue = (json['queue'] as List?) + ?.cast() + .map( + (json) => mediaItemFromJson(json.cast())) + .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 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 getMediaItem(String mediaId) async => - _queue!.firstWhereOrNull((mediaItem) => mediaItem.id == mediaId); + queue.value.firstWhereOrNull((mediaItem) => mediaItem.id == mediaId); @override Future playMediaItem(MediaItem mediaItem) => playFromMediaId(mediaItem.id); - // TODO: implement shuffle and repeat @override Future setRepeatMode(AudioServiceRepeatMode repeatMode) => - super.setRepeatMode(repeatMode); + _player.setLoopMode(repeatMode.toLoopMode()); @override - Future setShuffleMode(AudioServiceShuffleMode shuffleMode) => - super.setShuffleMode(shuffleMode); + Future 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?) diff --git a/lib/main.dart b/lib/main.dart index 247631a..5f9980e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -41,21 +41,7 @@ void main() async { //Do on BG playerHelper.authorizeLastFM(); - - // 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, - ), - ); + await playerHelper.initAudioHandler(); runApp(FreezerApp()); } @@ -66,15 +52,33 @@ class FreezerApp extends StatefulWidget { } class _FreezerAppState extends State { + late StreamSubscription _playbackStateSub; + @override void initState() { + _initStateAsync(); //Make update theme global updateTheme = _updateTheme; super.initState(); } + Future _initStateAsync() async { + _playbackStateChanged(audioHandler.playbackState.value); + _playbackStateSub = + audioHandler.playbackState.listen(_playbackStateChanged); + } + + Future _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 { //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); }); } diff --git a/lib/page_routes/basic_page_route.dart b/lib/page_routes/basic_page_route.dart new file mode 100644 index 0000000..1649577 --- /dev/null +++ b/lib/page_routes/basic_page_route.dart @@ -0,0 +1,20 @@ +import 'package:flutter/widgets.dart'; + +abstract class BasicPageRoute extends PageRoute { + 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; +} diff --git a/lib/page_routes/blur_slide.dart b/lib/page_routes/blur_slide.dart new file mode 100644 index 0000000..90a802f --- /dev/null +++ b/lib/page_routes/blur_slide.dart @@ -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 extends BasicPageRoute { + 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 animation, + Animation secondaryAnimation) => + builder(context); + + @override + Widget buildTransitions(BuildContext context, Animation _animation, + Animation 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), + ]); + } +} diff --git a/lib/page_routes/fade.dart b/lib/page_routes/fade.dart new file mode 100644 index 0000000..205a2c4 --- /dev/null +++ b/lib/page_routes/fade.dart @@ -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 extends BasicPageRoute { + 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 animation, + Animation secondaryAnimation) => + builder(context); + + @override + Widget buildTransitions(BuildContext context, Animation animation, + Animation 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; + } +} diff --git a/lib/settings.dart b/lib/settings.dart index f34b176..a06200d 100644 --- a/lib/settings.dart +++ b/lib/settings.dart @@ -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? tags; + late List 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, diff --git a/lib/settings.g.dart b/lib/settings.g.dart index fa3afec..1228e7e 100644 --- a/lib/settings.g.dart +++ b/lib/settings.g.dart @@ -70,6 +70,12 @@ Settings _$SettingsFromJson(Map 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 _$SettingsToJson(Settings instance) => { '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 json) => SpotifyCredentialsSave( diff --git a/lib/ui/animated_blur.dart b/lib/ui/animated_blur.dart new file mode 100644 index 0000000..60018c0 --- /dev/null +++ b/lib/ui/animated_blur.dart @@ -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); + }); + } +} diff --git a/lib/ui/cached_image.dart b/lib/ui/cached_image.dart index 3ba4f27..2414388 100644 --- a/lib/ui/cached_image.dart +++ b/lib/ui/cached_image.dart @@ -21,7 +21,7 @@ class ImagesDatabase { Future getPrimaryColor(String url) async { PaletteGenerator paletteGenerator = await getPaletteGenerator(url); - return paletteGenerator.colors.first; + return paletteGenerator.dominantColor!.color; } Future 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 { 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 { @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))), + ); })); }, ); diff --git a/lib/ui/details_screens.dart b/lib/ui/details_screens.dart index 718d060..daa5433 100644 --- a/lib/ui/details_screens.dart +++ b/lib/ui/details_screens.dart @@ -202,10 +202,7 @@ class _AlbumDetailsState extends State { //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 { ), ...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 _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: [ @@ -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 { @override Widget build(BuildContext context) { return Scaffold( - appBar: FreezerAppBar(_show!.name), + appBar: FreezerAppBar(_show!.name!), body: ListView( children: [ Padding( diff --git a/lib/ui/downloads_screen.dart b/lib/ui/downloads_screen.dart index eb2c1f2..72fc1a9 100644 --- a/lib/ui/downloads_screen.dart +++ b/lib/ui/downloads_screen.dart @@ -18,7 +18,7 @@ class DownloadsScreen extends StatefulWidget { class _DownloadsScreenState extends State { List downloads = []; - StreamSubscription? _stateSubscription; + late StreamSubscription _stateSubscription; //Sublists List get downloading => downloads @@ -70,8 +70,7 @@ class _DownloadsScreenState extends State { @override void dispose() { - _stateSubscription?.cancel(); - _stateSubscription = null; + _stateSubscription.cancel(); super.dispose(); } @@ -96,13 +95,13 @@ class _DownloadsScreenState extends State { ), 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(); diff --git a/lib/ui/elements.dart b/lib/ui/elements.dart index ebe8116..648ccdb 100644 --- a/lib/ui/elements.dart +++ b/lib/ui/elements.dart @@ -32,41 +32,45 @@ class EmptyLeading extends StatelessWidget { } class FreezerAppBar extends StatelessWidget implements PreferredSizeWidget { - final String? title; - final List actions; - final Widget? bottom; + final String title; + final List? 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), ); } } diff --git a/lib/ui/home_screen.dart b/lib/ui/home_screen.dart index 9811cf0..1d74421 100644 --- a/lib/ui/home_screen.dart +++ b/lib/ui/home_screen.dart @@ -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: diff --git a/lib/ui/library.dart b/lib/ui/library.dart index 30c9b43..1ecb21a 100644 --- a/lib/ui/library.dart +++ b/lib/ui/library.dart @@ -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 { ? _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 { }, 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 { ...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 { 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 { 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 { 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 { 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 { 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 { 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: () { diff --git a/lib/ui/login_screen.dart b/lib/ui/login_screen.dart index 5fce6d8..26517ff 100644 --- a/lib/ui/login_screen.dart +++ b/lib/ui/login_screen.dart @@ -215,9 +215,9 @@ class _LoginWidgetState extends State { OutlinedButton( child: Text('Login using browser'.i18n), onPressed: () { - Navigator.of(context).push(MaterialPageRoute( + Navigator.of(context).pushRoute( builder: (context) => - LoginBrowser(_update))); + LoginBrowser(_update)); }, ), OutlinedButton( diff --git a/lib/ui/lyrics.dart b/lib/ui/lyrics.dart deleted file mode 100644 index 41ee275..0000000 --- a/lib/ui/lyrics.dart +++ /dev/null @@ -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 { - 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 _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 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), - ], - ), - ), - ); - } -} diff --git a/lib/ui/lyrics_screen.dart b/lib/ui/lyrics_screen.dart new file mode 100644 index 0000000..1e8960c --- /dev/null +++ b/lib/ui/lyrics_screen.dart @@ -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 { + 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 _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( + 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 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), + ], + ), + ); + } +} diff --git a/lib/ui/player_bar.dart b/lib/ui/player_bar.dart index 4e5ebeb..60270bf 100644 --- a/lib/ui/player_bar.dart +++ b/lib/ui/player_bar.dart @@ -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 { 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 { 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 { @override Widget build(BuildContext context) { - var focusNode = FocusNode(); return _isNothingPlaying ? const SizedBox() : GestureDetector( @@ -72,35 +85,35 @@ class _PlayerBarState extends State { child: Column(mainAxisSize: MainAxisSize.min, children: [ StreamBuilder( 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 { 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 { ]), ); } + + 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>( - stream: audioHandler.queue, + return StreamBuilder( + 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 with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _animation; + late AnimationController _controller = + AnimationController(vsync: this, duration: Duration(milliseconds: 200)); + late Animation _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); } } diff --git a/lib/ui/player_screen.dart b/lib/ui/player_screen.dart index 85b5a0f..3c3b435 100644 --- a/lib/ui/player_screen.dart +++ b/lib/ui/player_screen.dart @@ -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 { - 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? 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( - 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( + builder: (context, provider, child) { + return _buildChild(context, provider, child!); + }, + child: widgetChild, + ); + if (appBar == null) + widgetChild = AnnotatedRegion( + value: getSystemUiOverlayStyle(context, enabled: enabled), + child: widgetChild, + ); + return widgetChild; } } @@ -285,45 +320,8 @@ class _PlayerScreenVerticalState extends State { height: 1000.w, ), ), + PlayerTextSubtext(textSize: 64.sp), const SizedBox(height: 4.0), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - 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 { } } +class PlayerTextSubtext extends StatelessWidget { + final double textSize; + const PlayerTextSubtext({Key? key, required this.textSize}) : super(key: key); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: audioHandler.mediaItem, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox(); + } + final currentMediaItem = snapshot.data!; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + 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 { // 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 { } } -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 { +class _ShuffleButtonState extends State { + @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 { Icon get libraryIcon { if (cache.checkTrackFavorite( Track.fromMediaItem(audioHandler.mediaItem.value!))) { @@ -491,6 +564,34 @@ class _PlaybackControlsState extends State { ); } + @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 { 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(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 { - 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 { 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>( + 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 { } } -class QueueScreen extends StatefulWidget { - @override - _QueueScreenState createState() => _QueueScreenState(); -} - -class _QueueScreenState extends State { - 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 _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: [ - 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: [ 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.value( + value: Provider.of(context), child: LyricsScreen()); + if (settings.playerBackgroundOnLyrics) { + Navigator.of(context).push(FadePageRoute(builder: builder)); + return; + } + Navigator.of(context).pushRoute(builder: builder); + } } diff --git a/lib/ui/queue_screen.dart b/lib/ui/queue_screen.dart new file mode 100644 index 0000000..7178080 --- /dev/null +++ b/lib/ui/queue_screen.dart @@ -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 { + 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 _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: [ + // 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(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(); + // 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), + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/ui/search.dart b/lib/ui/search.dart index 2ac7eb1..70e44a4 100644 --- a/lib/ui/search.dart +++ b/lib/ui/search.dart @@ -75,11 +75,11 @@ class _SearchScreenState extends State { 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 { 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 { 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 { 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 { channel: DeezerChannel(target: 'channels/explore'))), ), - )), + ), ) ], ) @@ -337,8 +337,8 @@ class _SearchScreenState extends State { 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 { 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 { 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); diff --git a/lib/ui/settings_screen.dart b/lib/ui/settings_screen.dart index c39415c..5dffe02 100644 --- a/lib/ui/settings_screen.dart +++ b/lib/ui/settings_screen.dart @@ -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 { 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 { 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 { 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 { ), 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 { 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 { 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 { 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: [ + 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 { - AudioQuality? _quality; + late AudioQuality _quality; @override void initState() { @@ -481,7 +560,7 @@ class _QualityPickerState extends State { } //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 { - double _downloadThreads = settings.downloadThreads!.toDouble(); + double _downloadThreads = settings.downloadThreads.toDouble(); TextEditingController _artistSeparatorController = TextEditingController(text: settings.artistSeparator); @@ -809,14 +888,14 @@ class _DownloadsSettingsState extends State { 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 { _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 { 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 { ), 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 { 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 { 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 { 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 { 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 { 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 { 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 { )) .toList(), onChanged: (int? n) async { + if (n == null) return; setState(() { settings.albumArtResolution = n; }); @@ -1000,7 +1080,7 @@ class _DownloadsSettingsState extends State { 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 { 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 { (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 { 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 { }); return AlertDialog( title: Text('Logging in...'.i18n), - content: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.center, - children: [CircularProgressIndicator()], - )); + content: + const Center(child: CircularProgressIndicator())); }); }, ), @@ -1145,7 +1222,7 @@ class _GeneralSettingsState extends State { '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 { 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(); diff --git a/lib/ui/tiles.dart b/lib/ui/tiles.dart index 493a19b..f8234c2 100644 --- a/lib/ui/tiles.dart +++ b/lib/ui/tiles.dart @@ -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 { - StreamSubscription? _subscription; + late StreamSubscription _subscription; bool _isOffline = false; bool _isHighlighted = false; @@ -34,7 +34,7 @@ class _TrackTileState extends State { //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 { @override void dispose() { - _subscription?.cancel(); + _subscription.cancel(); super.dispose(); } @@ -56,18 +56,18 @@ class _TrackTileState extends State { 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 { 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 { Container( width: 42.0, child: Text( - widget.track!.durationString, + widget.track.durationString, textAlign: TextAlign.center, ), ), @@ -108,8 +108,8 @@ class _TrackTileState extends State { 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: [ - 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: [ + 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, ), ); diff --git a/pubspec.lock b/pubspec.lock index 1f11fe0..6f9d598 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 0f0f1e7..a76bc25 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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