better connectivity checks + error handling for linux

desktop UI
add setting for navigation rail
changes to DeezerAudioSource: get quality when needed and cache url to avoid re generating and resending too many HEAD requests
This commit is contained in:
Pato05 2023-10-17 00:22:50 +02:00
parent 6aa596177f
commit 6f1fb73ed8
No known key found for this signature in database
GPG Key ID: ED4C6F9C3D574FB6
22 changed files with 1302 additions and 918 deletions

View File

@ -59,8 +59,7 @@ android {
buildTypes {
release {
// TODO: Put back signingConfig.release
signingConfig signingConfigs.debug
signingConfig signingConfigs.release
shrinkResources false
minifyEnabled true
}

View File

@ -27,7 +27,7 @@ typedef _IsolateMessage = (
class DeezerAudioSource extends StreamAudioSource {
final _logger = Logger("DeezerAudioSource");
late AudioQuality? _quality;
late AudioQuality Function() _getQuality;
late AudioQuality? _initialQuality;
late String _trackId;
late String _md5origin;
@ -35,24 +35,26 @@ class DeezerAudioSource extends StreamAudioSource {
final StreamInfoCallback? onStreamObtained;
// some cache
AudioQuality? _currentQuality;
int? _cachedSourceLength;
String? _cachedContentType;
Uri? _downloadUrl;
DeezerAudioSource({
required AudioQuality quality,
required AudioQuality Function() getQuality,
required String trackId,
required String md5origin,
required String mediaVersion,
this.onStreamObtained,
}) {
_quality = quality;
_getQuality = getQuality;
_initialQuality = quality;
_trackId = trackId;
_md5origin = md5origin;
_mediaVersion = mediaVersion;
}
AudioQuality? get quality => _quality;
AudioQuality? get quality => _currentQuality;
String get trackId => _trackId;
String get md5origin => _md5origin;
String get mediaVersion => _mediaVersion;
@ -71,7 +73,7 @@ class DeezerAudioSource extends StreamAudioSource {
return await _qualityFallback();
} on QualityException {
_logger.warning("quality fallback failed! trying trackId fallback");
_quality = _initialQuality;
_currentQuality = _initialQuality;
}
Map? privateJson;
@ -128,16 +130,16 @@ class DeezerAudioSource extends StreamAudioSource {
if (rc > 400) {
_logger.warning(
"quality fallback, response code: $rc, current quality: $quality");
switch (_quality) {
switch (_currentQuality) {
case AudioQuality.FLAC:
_quality = AudioQuality.MP3_320;
_currentQuality = AudioQuality.MP3_320;
break;
case AudioQuality.MP3_320:
_quality = AudioQuality.MP3_128;
_currentQuality = AudioQuality.MP3_128;
break;
case AudioQuality.MP3_128:
default:
_quality = null;
_currentQuality = null;
throw QualityException("No quality to fallback to!");
}
@ -220,7 +222,7 @@ class DeezerAudioSource extends StreamAudioSource {
final deezerStart = start - dropBytes;
int counter = deezerStart ~/ chunkSize;
final buffer = List<int>.empty(growable: true);
final key = await flutter.compute(getKey, trackId);
final key = getKey(trackId);
await for (var bytes in source) {
if (dropBytes > 0) {
@ -277,11 +279,18 @@ class DeezerAudioSource extends StreamAudioSource {
throw Exception("Authorization failed!");
}
// determine quality to use
_currentQuality = _getQuality!.call();
final Uri uri;
try {
uri = await _fallbackUrl();
} on QualityException {
rethrow;
if (_downloadUrl != null) {
uri = _downloadUrl!;
} else {
try {
_downloadUrl = uri = await _fallbackUrl();
} on QualityException {
rethrow;
}
}
_logger.fine("Downloading track from ${uri.toString()}");
final int deezerStart = start - (start % 2048);

View File

@ -80,8 +80,11 @@ class Track extends DeezerMediaItem {
String get artistString => artists == null
? ""
: artists!.map<String?>((art) => art.name).join(', ');
String get durationString =>
"${duration!.inMinutes}:${duration!.inSeconds.remainder(60).toString().padLeft(2, '0')}";
String get durationString => durationAsString(duration!);
static String durationAsString(Duration duration) {
return "${duration.inMinutes}:${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}";
}
//MediaItem
Future<MediaItem> toMediaItem() async {

View File

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart';
import 'package:disk_space_plus/disk_space_plus.dart';
import 'package:filesize/filesize.dart';
@ -20,8 +21,7 @@ final downloadManager = DownloadManager();
class DownloadManager {
// DownloadManager currently only supports android
static bool get isSupported => Platform.isAndroid;
static bool get isSupported => Platform.isAndroid;
//Platform channels
static MethodChannel platform = const MethodChannel('f.f.freezer/native');
@ -37,7 +37,7 @@ class DownloadManager {
//Start/Resume downloads
Future start() async {
if (!Platform.isAndroid) return;
if (!isSupported) return;
//Returns whether service is bound or not, the delay is really shitty/hacky way, until i find a real solution
await updateServiceSettings();
@ -46,13 +46,13 @@ class DownloadManager {
//Stop/Pause downloads
Future stop() async {
if (!Platform.isAndroid) return;
if (!isSupported) return;
await platform.invokeMethod('stop');
}
Future init() async {
if (!Platform.isAndroid) return;
if (!isSupported) return;
//Remove old DB
File oldDbFile = File(p.join((await getDatabasesPath()), 'offline.db'));
if (await oldDbFile.exists()) {
@ -100,7 +100,7 @@ class DownloadManager {
//Get all downloads from db
Future<List<Download>> getDownloads() async {
if (!Platform.isAndroid) return [];
if (!isSupported) return [];
List raw = await platform.invokeMethod('getDownloads');
return raw.map((d) => Download.fromJson(d)).toList();
@ -158,7 +158,7 @@ class DownloadManager {
Future<bool> addOfflineTrack(Track track,
{private = true, BuildContext? context, isSingleton = false}) async {
if (!Platform.isAndroid) return false;
if (!isSupported) return false;
//Permission
if (!private && !(await checkPermission())) return false;
@ -241,7 +241,7 @@ class DownloadManager {
Future addOfflinePlaylist(Playlist? playlist,
{private = true, BuildContext? context, AudioQuality? quality}) async {
if (!Platform.isAndroid) return false;
if (!isSupported) return false;
//Permission
if (!private && !(await checkPermission())) return;
@ -338,7 +338,7 @@ class DownloadManager {
//Get all offline available tracks
Future<List<Track?>> allOfflineTracks() async {
if (!Platform.isAndroid) return [];
if (!isSupported) return [];
List rawTracks =
await db.query('Tracks', where: 'offline == 1', columns: ['id']);
@ -352,6 +352,8 @@ class DownloadManager {
//Get all offline albums
Future<List<Album>> getOfflineAlbums() async {
if (!isSupported) return [];
List rawAlbums =
await db.query('Albums', where: 'offline == 1', columns: ['id']);
List<Album> out = [];
@ -396,6 +398,7 @@ class DownloadManager {
//Get all offline playlists
Future<List<Playlist>> getOfflinePlaylists() async {
if (!isSupported) return [];
final rawPlaylists = await db.query('Playlists', columns: ['id']);
final out = <Playlist>[];
for (final rawPlaylist in rawPlaylists) {
@ -470,6 +473,7 @@ class DownloadManager {
}
Future removeOfflinePlaylist(String? id) async {
if (!isSupported) return;
//Fetch playlist
List rawPlaylists =
await db.query('Playlists', where: 'id == ?', whereArgs: [id]);
@ -481,9 +485,11 @@ class DownloadManager {
}
//Check if album, track or playlist is offline
Future<bool> checkOffline(
{Album? album, Track? track, Playlist? playlist}) async {
if (!Platform.isAndroid) return false;
Future<bool> _checkOffline(
(Album? album, Track? track, Playlist? playlist) message) async {
if (!isSupported) return false;
final (album, track, playlist) = message;
//Track
if (track != null) {
@ -509,6 +515,10 @@ class DownloadManager {
return false;
}
Future<bool> checkOffline({Album? album, Track? track, Playlist? playlist}) {
return compute(_checkOffline, (album, track, playlist));
}
//Offline search
Future<SearchResults> search(String? query) async {
SearchResults results =
@ -619,7 +629,7 @@ class DownloadManager {
//Send settings to download service
Future updateServiceSettings() async {
if (!Platform.isAndroid) return;
if (!isSupported) return;
await platform.invokeMethod(
'updateSettings', settings.getServiceSettings());
}
@ -639,21 +649,21 @@ class DownloadManager {
//Remove download from queue/finished
Future removeDownload(int? id) async {
if (!Platform.isAndroid) return;
if (!isSupported) return;
await platform.invokeMethod('removeDownload', {'id': id});
}
//Restart failed downloads
Future retryDownloads() async {
if (!Platform.isAndroid) return;
if (!isSupported) return;
await platform.invokeMethod('retryDownloads');
}
//Delete downloads by state
Future removeDownloads(DownloadState state) async {
if (!Platform.isAndroid) return;
if (!isSupported) return;
await platform.invokeMethod(
'removeDownloads', {'state': DownloadState.values.indexOf(state)});

View File

@ -354,6 +354,8 @@ class AudioPlayerTaskInitArguments {
}
class AudioPlayerTask extends BaseAudioHandler {
final _logger = Logger('AudioPlayerTask');
late AudioPlayer _player;
late ConcatenatingAudioSource _audioSource;
late DeezerAPI _deezerAPI;
@ -376,6 +378,7 @@ class AudioPlayerTask extends BaseAudioHandler {
StreamSubscription? _bufferPositionSubscription;
StreamSubscription? _audioSessionSubscription;
StreamSubscription? _visualizerSubscription;
StreamSubscription? _connectivitySubscription;
/// Android Auto helper class for navigation
late final AndroidAuto _androidAuto;
@ -384,6 +387,8 @@ class AudioPlayerTask extends BaseAudioHandler {
AudioQuality mobileQuality = AudioQuality.MP3_128;
AudioQuality wifiQuality = AudioQuality.MP3_128;
AudioQuality _currentQuality = AudioQuality.MP3_128;
/// Current queueSource (=> where playback has begun from)
QueueSource? queueSource;
@ -466,7 +471,7 @@ class AudioPlayerTask extends BaseAudioHandler {
//Update
_broadcastState();
}, onError: (Object e, StackTrace st) {
print('A stream error occurred: $e');
_logger.severe('A stream error occurred: $e');
});
_player.processingStateStream.listen((state) {
switch (state) {
@ -496,6 +501,20 @@ class AudioPlayerTask extends BaseAudioHandler {
//Load queue
// queue.add(_queue);
// Determine audio quality to use
try {
await Connectivity().checkConnectivity().then(_determineAudioQuality);
// listen for connectivity changes
_connectivitySubscription =
Connectivity().onConnectivityChanged.listen(_determineAudioQuality);
} catch (e) {
_logger.warning(
'Couldn\'t determine connection! Falling back to other (which may use wifi quality)');
// on error, return dummy value -- error can happen on linux if not using NetworkManager, for example
_determineAudioQuality(ConnectivityResult.other);
}
await _loadQueueFile();
if (initArgs.lastFMUsername != null && initArgs.lastFMPassword != null) {
@ -506,6 +525,21 @@ class AudioPlayerTask extends BaseAudioHandler {
customEvent.add({'action': 'onLoad'});
}
void _determineAudioQuality(ConnectivityResult result) {
switch (result) {
case ConnectivityResult.mobile:
case ConnectivityResult.bluetooth:
_currentQuality = mobileQuality;
case ConnectivityResult.other:
_currentQuality =
Platform.isLinux || Platform.isLinux || Platform.isMacOS
? wifiQuality
: mobileQuality;
default:
_currentQuality = wifiQuality;
}
}
@override
Future skipToQueueItem(int index) async {
_lastPosition = null;
@ -772,7 +806,9 @@ class AudioPlayerTask extends BaseAudioHandler {
if (!queue.hasValue || queue.value.isEmpty) {
return;
}
final sources = await Future.wait(queue.value.map(_mediaItemToAudioSource));
final sources =
await Future.wait(queue.value.map((e) => _mediaItemToAudioSource(e)));
_audioSource = ConcatenatingAudioSource(
children: sources,
@ -827,10 +863,6 @@ class AudioPlayerTask extends BaseAudioHandler {
//Due to current limitations of just_audio, quality fallback moved to DeezerDataSource in ExoPlayer
//This just returns fake url that contains metadata
List? playbackDetails = jsonDecode(mediaItem.extras!['playbackDetails']);
//Quality
ConnectivityResult conn = await Connectivity().checkConnectivity();
AudioQuality quality = mobileQuality;
if (conn == ConnectivityResult.wifi) quality = wifiQuality;
if ((playbackDetails ?? []).length < 2) {
throw Exception('not enough playback details');
@ -848,7 +880,7 @@ class AudioPlayerTask extends BaseAudioHandler {
// DON'T use the java backend anymore, useless.
return DeezerAudioSource(
quality: quality,
getQuality: () => _currentQuality,
trackId: mediaItem.id,
md5origin: playbackDetails![0],
mediaVersion: playbackDetails[1],

View File

@ -150,9 +150,7 @@ class _FreezerAppState extends State<FreezerApp> {
}
void _updateTheme() {
setState(() {
settings.themeData;
});
setState(() {});
}
Locale? _locale() {
@ -317,9 +315,8 @@ class MainScreenState extends State<MainScreen>
final playerScreenFocusNode = FocusScopeNode();
final playerBarFocusNode = FocusNode();
final _fancyScaffoldKey = GlobalKey<FancyScaffoldState>();
final routeObserver = RouteObserver();
late bool _isDesktop;
late bool isDesktop;
@override
void initState() {
@ -647,11 +644,11 @@ class MainScreenState extends State<MainScreen>
child: LayoutBuilder(builder: (context, constraints) {
// check if we're running on a desktop platform
final isLandscape = constraints.maxWidth > constraints.maxHeight;
_isDesktop = isLandscape && constraints.maxWidth > 1024;
isDesktop = isLandscape && constraints.maxWidth > 1024;
return FancyScaffold(
key: _fancyScaffoldKey,
bodyDrawer: _buildNavigationRail(_isDesktop),
bottomNavigationBar: buildBottomBar(_isDesktop),
bodyDrawer: _buildNavigationRail(isDesktop),
bottomNavigationBar: buildBottomBar(isDesktop),
bottomPanel: PlayerBar(
focusNode: playerBarFocusNode,
onTap: () =>
@ -678,7 +675,6 @@ class MainScreenState extends State<MainScreen>
skipTraversal: true,
canRequestFocus: false,
child: _MainRouteNavigator(
observers: [routeObserver],
navigatorKey: navigatorKey,
routes: {
Navigator.defaultRouteName: (context) =>
@ -805,16 +801,24 @@ class _ExtensibleNavigationRailState extends State<ExtensibleNavigationRail> {
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _extended = true),
onExit: (_) => setState(() => _extended = false),
child: NavigationRail(
extended: _extended,
destinations: widget.destinations,
selectedIndex: widget.selectedIndex,
onDestinationSelected: widget.onDestinationSelected,
),
final child = NavigationRail(
extended: _extended,
destinations: widget.destinations,
selectedIndex: widget.selectedIndex,
onDestinationSelected: widget.onDestinationSelected,
);
if (settings.navigationRailAppearance !=
NavigationRailAppearance.expand_on_hover) {
_extended = settings.navigationRailAppearance ==
NavigationRailAppearance.always_expanded;
return child;
}
return MouseRegion(
onEnter: (_) => setState(() => _extended = true),
onExit: (_) => setState(() => _extended = false),
child: child);
}
}

View File

@ -3,6 +3,11 @@ import 'package:freezer/page_routes/basic_page_route.dart';
import 'package:freezer/ui/animated_blur.dart';
class FadePageRoute<T> extends BasicPageRoute<T> {
@override
final bool barrierDismissible;
@override
final Color? barrierColor;
final WidgetBuilder builder;
final bool blur;
FadePageRoute({
@ -11,6 +16,8 @@ class FadePageRoute<T> extends BasicPageRoute<T> {
super.transitionDuration,
super.maintainState,
super.settings,
this.barrierColor,
this.barrierDismissible = false,
});
@override

View File

@ -166,6 +166,10 @@ class Settings {
@HiveField(47, defaultValue: false)
bool seekAsSkip = false;
@HiveField(48, defaultValue: NavigationRailAppearance.expand_on_hover)
NavigationRailAppearance navigationRailAppearance =
NavigationRailAppearance.expand_on_hover;
static LazyBox<Settings>? __box;
static Future<LazyBox<Settings>> get _box async =>
__box ??= await Hive.openLazyBox<Settings>('settings');
@ -415,3 +419,13 @@ class SpotifyCredentialsSave {
_$SpotifyCredentialsSaveFromJson(json);
Map<String, dynamic> toJson() => _$SpotifyCredentialsSaveToJson(this);
}
@HiveType(typeId: 34)
enum NavigationRailAppearance {
@HiveField(0)
expand_on_hover,
@HiveField(1)
always_expanded,
@HiveField(2)
icons_only,
}

View File

@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:freezer/page_routes/fade.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:photo_view/photo_view.dart';
@ -103,14 +105,14 @@ class _CachedImageState extends State<CachedImage> {
}
}
class ZoomableImage extends StatefulWidget {
class ZoomableImage extends StatelessWidget {
final String url;
final bool rounded;
final double? width;
final bool enableHero;
final Object? heroTag;
const ZoomableImage({
ZoomableImage({
super.key,
required this.url,
this.rounded = false,
@ -119,38 +121,15 @@ class ZoomableImage extends StatefulWidget {
this.heroTag,
});
@override
State<ZoomableImage> createState() => _ZoomableImageState();
}
class _ZoomableImageState extends State<ZoomableImage> {
PhotoViewController? controller;
bool photoViewOpened = false;
late final Object? _key =
widget.enableHero ? (widget.heroTag ?? UniqueKey()) : null;
@override
void initState() {
super.initState();
controller = PhotoViewController()..outputStateStream.listen(listener);
}
// Listener of PhotoView scale changes. Used for closing PhotoView by pinch-in
void listener(PhotoViewControllerValue value) {
if (value.scale! < 0.16 && photoViewOpened) {
Navigator.pop(context);
photoViewOpened =
false; // to avoid multiple pop() when picture are being scaled out too slowly
}
}
late final Object? _key = enableHero ? (heroTag ?? UniqueKey()) : null;
@override
Widget build(BuildContext context) {
print('key: $_key');
final image = CachedImage(
url: widget.url,
rounded: widget.rounded,
width: widget.width,
url: url,
rounded: rounded,
width: width,
fullThumb: true,
);
final child = _key != null
@ -165,25 +144,72 @@ class _ZoomableImageState extends State<ZoomableImage> {
child: child,
),
onTap: () {
Navigator.of(context).push(PageRouteBuilder(
opaque: false, // transparent background
pageBuilder: (context, animation, __) {
photoViewOpened = true;
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))),
);
}));
Navigator.of(context).push(FadePageRoute(
builder: (context) =>
ZoomableImageRoute(imageUrl: url, heroKey: _key),
barrierDismissible: true));
},
);
}
}
class ZoomableImageRoute extends StatefulWidget {
final Object? heroKey;
final String imageUrl;
const ZoomableImageRoute({required this.imageUrl, super.key, this.heroKey});
@override
State<ZoomableImageRoute> createState() => _ZoomableImageRouteState();
}
class _ZoomableImageRouteState extends State<ZoomableImageRoute> {
bool photoViewOpened = false;
final controller = PhotoViewController();
final _focusNode = FocusNode();
@override
void initState() {
controller.outputStateStream.listen(listener);
_focusNode.requestFocus();
super.initState();
}
@override
void dispose() {
controller.dispose();
_focusNode.dispose();
super.dispose();
}
void listener(PhotoViewControllerValue value) {
if (value.scale! < 0.16 && photoViewOpened) {
Navigator.pop(context);
photoViewOpened =
false; // to avoid multiple pop() when picture are being scaled out too slowly
}
}
@override
Widget build(BuildContext context) {
return RawKeyboardListener(
focusNode: _focusNode,
onKey: (event) {
if (event is! KeyUpEvent) return;
if (event.isKeyPressed(LogicalKeyboardKey.escape)) {
Navigator.pop(context);
}
},
child: PhotoView(
imageProvider: CachedNetworkImageProvider(widget.imageUrl),
maxScale: 8.0,
minScale: 0.2,
controller: controller,
heroAttributes: widget.heroKey == null
? null
: PhotoViewHeroAttributes(tag: widget.heroKey!),
backgroundDecoration:
const BoxDecoration(color: Color.fromARGB(0x90, 0, 0, 0))),
);
}
}

View File

@ -1,7 +1,9 @@
import 'dart:async';
import 'dart:math';
import 'package:draggable_scrollbar/draggable_scrollbar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:fluttericon/font_awesome5_icons.dart';
import 'package:freezer/api/cache.dart';
import 'package:freezer/api/deezer.dart';
@ -77,10 +79,13 @@ class _AlbumDetailsState extends State<AlbumDetails> {
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const SizedBox(height: 8.0),
ZoomableImage(
url: album!.art!.full,
width: MediaQuery.of(context).size.width / 2,
rounded: true,
ConstrainedBox(
constraints: BoxConstraints.loose(
MediaQuery.of(context).size / 3),
child: ZoomableImage(
url: album!.art!.full,
rounded: true,
),
),
const SizedBox(height: 8.0),
Text(
@ -228,12 +233,14 @@ class _AlbumDetailsState extends State<AlbumDetails> {
),
...List.generate(
tracks.length,
(i) => TrackTile(tracks[i]!, onTap: () {
(i) =>
TrackTile.fromTrack(tracks[i]!, onTap: () {
playerHelper.playFromAlbum(
album!, tracks[i]!.id);
}, onHold: () {
}, onSecondary: (details) {
MenuSheet m = MenuSheet(context);
m.defaultTrackMenu(tracks[i]!);
m.defaultTrackMenu(tracks[i]!,
details: details);
}))
],
);
@ -299,18 +306,25 @@ class _MakeAlbumOfflineState extends State<MakeAlbumOffline> {
}
}
class ArtistDetails extends StatelessWidget {
late final Artist artist;
late final Future? _future;
class ArtistDetails extends StatefulWidget {
final Artist artist;
ArtistDetails(Artist artist, {Key? key}) : super(key: key) {
FutureOr<Artist> future = _loadArtist(artist);
const ArtistDetails(this.artist, {super.key});
@override
State<ArtistDetails> createState() => _ArtistDetailsState();
}
class _ArtistDetailsState extends State<ArtistDetails> {
late final Future<Artist> _future;
void initState() {
FutureOr<Artist> future = _loadArtist(widget.artist);
if (future is Artist) {
this.artist = future;
_future = null;
_future = Future.value(widget.artist);
} else {
_future = future.then((value) => this.artist = value);
_future = future;
}
super.initState();
}
FutureOr<Artist> _loadArtist(Artist artist) {
@ -318,85 +332,95 @@ class ArtistDetails extends StatelessWidget {
if ((artist.albums ?? []).isEmpty) {
return deezerAPI.artist(artist.id);
}
return artist;
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: FutureBuilder(
future: _future ?? Future.value(),
builder: (BuildContext context, AsyncSnapshot snapshot) {
body: FutureBuilder<Artist>(
future: _future,
builder: (BuildContext context, snapshot) {
//Error / not done
if (snapshot.hasError) return const ErrorScreen();
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
final artist = snapshot.data!;
return ListView(
children: <Widget>[
const SizedBox(height: 4.0),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
ZoomableImage(
url: artist.picture!.full,
width: MediaQuery.of(context).size.width / 2 - 8,
rounded: true,
),
SizedBox(
width: MediaQuery.of(context).size.width / 2 - 24,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text(
artist.name!,
overflow: TextOverflow.ellipsis,
maxLines: 4,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 24.0, fontWeight: FontWeight.bold),
Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
height: MediaQuery.of(context).size.height / 3,
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
Flexible(
child: ZoomableImage(
url: widget.artist.picture!.full,
rounded: true,
),
const SizedBox(height: 8.0),
Row(
mainAxisSize: MainAxisSize.min,
),
SizedBox(
width: min(
MediaQuery.of(context).size.width / 16, 60.0)),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Icon(
Icons.people,
size: 32.0,
semanticLabel: "Fans".i18n,
),
const SizedBox(width: 8.0),
Text(
artist.fansString,
style: const TextStyle(fontSize: 16),
artist.name!,
overflow: TextOverflow.ellipsis,
maxLines: 4,
style: const TextStyle(
fontSize: 24.0, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 4.0),
Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(
Icons.album,
size: 32.0,
semanticLabel: "Albums".i18n,
const SizedBox(height: 8.0),
Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(
Icons.people,
size: 32.0,
semanticLabel: "Fans".i18n,
),
const SizedBox(width: 8.0),
Text(
artist.fansString,
style: const TextStyle(fontSize: 16),
),
],
),
const SizedBox(width: 8.0),
Text(
artist.albumCount.toString(),
style: const TextStyle(fontSize: 16),
const SizedBox(height: 4.0),
Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(
Icons.album,
size: 32.0,
semanticLabel: "Albums".i18n,
),
const SizedBox(width: 8.0),
Text(
widget.artist.albumCount.toString(),
style: const TextStyle(fontSize: 16),
)
],
)
],
)
],
),
),
),
],
),
],
),
),
const SizedBox(height: 4.0),
const FreezerDivider(),
Row(
mainAxisSize: MainAxisSize.max,
@ -411,7 +435,7 @@ class ArtistDetails extends StatelessWidget {
],
),
onPressed: () async {
await deezerAPI.addFavoriteArtist(artist.id);
await deezerAPI.addFavoriteArtist(widget.artist.id);
ScaffoldMessenger.of(context)
.snack('Added to library'.i18n);
},
@ -485,15 +509,15 @@ class ArtistDetails extends StatelessWidget {
return const SizedBox(height: 0.0, width: 0.0);
}
Track t = artist.topTracks![i];
return TrackTile(
return TrackTile.fromTrack(
t,
onTap: () {
playerHelper.playFromTopTracks(
artist.topTracks!, t.id, artist);
},
onHold: () {
onSecondary: (details) {
MenuSheet mi = MenuSheet(context);
mi.defaultTrackMenu(t);
mi.defaultTrackMenu(t, details: details);
},
);
}),
@ -542,9 +566,9 @@ class ArtistDetails extends StatelessWidget {
Navigator.of(context)
.pushRoute(builder: (context) => AlbumDetails(a));
},
onHold: () {
onSecondary: (details) {
MenuSheet m = MenuSheet(context);
m.defaultAlbumMenu(a);
m.defaultAlbumMenu(a, details: details);
},
);
})
@ -603,9 +627,9 @@ class _DiscographyScreenState extends State<DiscographyScreen> {
a,
onTap: () => Navigator.of(context)
.push(MaterialPageRoute(builder: (context) => AlbumDetails(a))),
onHold: () {
onSecondary: (details) {
MenuSheet m = MenuSheet(context);
m.defaultAlbumMenu(a);
m.defaultAlbumMenu(a, details: details);
},
);
@ -857,85 +881,87 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
child: ListView(
controller: _scrollController,
children: <Widget>[
Container(
height: 4.0,
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
mainAxisSize: MainAxisSize.max,
children: <Widget>[
CachedImage(
url: playlist!.image!.full,
height: MediaQuery.of(context).size.width / 2 - 8,
rounded: true,
fullThumb: true,
),
SizedBox(
width: MediaQuery.of(context).size.width / 2 - 8,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
playlist!.title!,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
maxLines: 3,
style: const TextStyle(
fontSize: 20.0, fontWeight: FontWeight.bold),
),
Container(height: 4.0),
Text(
playlist!.user!.name ?? '',
overflow: TextOverflow.ellipsis,
maxLines: 2,
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).primaryColor,
fontSize: 17.0),
),
Container(height: 10.0),
Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(
Icons.audiotrack,
size: 32.0,
semanticLabel: "Tracks".i18n,
),
Container(
width: 8.0,
),
Text(
(playlist!.trackCount ?? playlist!.tracks!.length)
.toString(),
style: const TextStyle(fontSize: 16),
)
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(
Icons.timelapse,
size: 32.0,
semanticLabel: "Duration".i18n,
),
Container(
width: 8.0,
),
Text(
playlist!.durationString,
style: const TextStyle(fontSize: 16),
)
],
),
],
const SizedBox(height: 4.0),
ConstrainedBox(
constraints: BoxConstraints.tight(
Size.fromHeight(MediaQuery.of(context).size.height / 3)),
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
children: <Widget>[
Flexible(
child: CachedImage(
url: playlist!.image!.full,
rounded: true,
fullThumb: true,
),
),
)
],
SizedBox(
width: min(MediaQuery.of(context).size.width / 16, 60.0)),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
playlist!.title!,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.start,
maxLines: 3,
style: const TextStyle(
fontSize: 20.0, fontWeight: FontWeight.bold),
),
Text(
playlist!.user!.name ?? '',
overflow: TextOverflow.ellipsis,
maxLines: 2,
textAlign: TextAlign.start,
style: TextStyle(
color: Theme.of(context).primaryColor,
fontSize: 17.0),
),
const SizedBox(height: 16.0),
Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(
Icons.audiotrack,
size: 20.0,
semanticLabel: "Tracks".i18n,
),
const SizedBox(width: 8.0),
Text(
(playlist!.trackCount ?? playlist!.tracks!.length)
.toString(),
style: const TextStyle(fontSize: 16),
)
],
),
const SizedBox(height: 6.0),
Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(
Icons.timelapse,
size: 32.0,
semanticLabel: "Duration".i18n,
),
const SizedBox(width: 8.0),
Text(
playlist!.durationString,
style: const TextStyle(fontSize: 16),
)
],
),
],
),
),
],
),
),
),
if (playlist!.description != null &&
@ -1057,13 +1083,13 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
const FreezerDivider(),
...List.generate(playlist!.tracks!.length, (i) {
Track t = sorted[i];
return TrackTile(t, onTap: () {
return TrackTile.fromTrack(t, onTap: () {
Playlist p = Playlist(
title: playlist!.title, id: playlist!.id, tracks: sorted);
playerHelper.playFromPlaylist(p, t.id);
}, onHold: () {
}, onSecondary: (details) {
MenuSheet m = MenuSheet(context);
m.defaultTrackMenu(t, options: [
m.defaultTrackMenu(t, details: details, options: [
(playlist!.user!.id == deezerAPI.userId)
? m.removeFromPlaylist(t, playlist)
: const SizedBox(
@ -1138,9 +1164,7 @@ class _MakePlaylistOfflineState extends State<MakePlaylistOffline> {
});
},
),
Container(
width: 4.0,
),
const SizedBox(width: 4.0),
Text(
'Offline'.i18n,
style: const TextStyle(fontSize: 16),

View File

@ -328,9 +328,9 @@ class HomePageItemWidget extends StatelessWidget {
Navigator.of(context)
.pushRoute(builder: (context) => ArtistDetails(item.value));
},
onHold: () {
onSecondary: (details) {
MenuSheet m = MenuSheet(context);
m.defaultArtistMenu(item.value);
m.defaultArtistMenu(item.value, details: details);
},
);
case HomePageItemType.PLAYLIST:

View File

@ -166,57 +166,58 @@ class LibraryScreen extends StatelessWidget {
},
),
if (DownloadManager.isSupported)
ExpansionTile(
title: Text('Statistics'.i18n),
leading: const LeadingIcon(Icons.insert_chart, color: Colors.grey),
children: <Widget>[
FutureBuilder(
future: downloadManager.getStats(),
builder: (context, snapshot) {
if (snapshot.hasError) return const ErrorScreen();
if (!snapshot.hasData) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[CircularProgressIndicator()],
),
ExpansionTile(
title: Text('Statistics'.i18n),
leading:
const LeadingIcon(Icons.insert_chart, color: Colors.grey),
children: <Widget>[
FutureBuilder(
future: downloadManager.getStats(),
builder: (context, snapshot) {
if (snapshot.hasError) return const ErrorScreen();
if (!snapshot.hasData) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[CircularProgressIndicator()],
),
);
}
List<String> data = snapshot.data!;
return Column(
children: <Widget>[
ListTile(
title: Text('Offline tracks'.i18n),
leading: const Icon(Icons.audiotrack),
trailing: Text(data[0]),
),
ListTile(
title: Text('Offline albums'.i18n),
leading: const Icon(Icons.album),
trailing: Text(data[1]),
),
ListTile(
title: Text('Offline playlists'.i18n),
leading: const Icon(Icons.playlist_add),
trailing: Text(data[2]),
),
ListTile(
title: Text('Offline size'.i18n),
leading: const Icon(Icons.sd_card),
trailing: Text(data[3]),
),
ListTile(
title: Text('Free space'.i18n),
leading: const Icon(Icons.disc_full),
trailing: Text(data[4]),
),
],
);
}
List<String> data = snapshot.data!;
return Column(
children: <Widget>[
ListTile(
title: Text('Offline tracks'.i18n),
leading: const Icon(Icons.audiotrack),
trailing: Text(data[0]),
),
ListTile(
title: Text('Offline albums'.i18n),
leading: const Icon(Icons.album),
trailing: Text(data[1]),
),
ListTile(
title: Text('Offline playlists'.i18n),
leading: const Icon(Icons.playlist_add),
trailing: Text(data[2]),
),
ListTile(
title: Text('Offline size'.i18n),
leading: const Icon(Icons.sd_card),
trailing: Text(data[3]),
),
ListTile(
title: Text('Free space'.i18n),
leading: const Icon(Icons.disc_full),
trailing: Text(data[4]),
),
],
);
},
)
],
)
},
)
],
)
],
),
);
@ -501,7 +502,7 @@ class _LibraryTracksState extends State<LibraryTracks> {
Track? t = (tracks.length == (trackCount ?? 0))
? _sorted[i]
: tracks[i];
return TrackTile(
return TrackTile.fromTrack(
t,
onTap: () {
playerHelper.playFromTrackList(
@ -514,9 +515,9 @@ class _LibraryTracksState extends State<LibraryTracks> {
text: 'Favorites'.i18n,
source: 'playlist'));
},
onHold: () {
onSecondary: (details) {
MenuSheet m = MenuSheet(context);
m.defaultTrackMenu(t, onRemove: () {
m.defaultTrackMenu(t, details: details, onRemove: () {
setState(() {
tracks.removeWhere((track) => t.id == track.id);
});
@ -533,7 +534,7 @@ class _LibraryTracksState extends State<LibraryTracks> {
),
const SizedBox(height: 8.0),
for (final track in allTracks)
TrackTile(track!, onTap: () {
TrackTile.fromTrack(track!, onTap: () {
playerHelper.playFromTrackList(
allTracks,
track.id,
@ -541,8 +542,9 @@ class _LibraryTracksState extends State<LibraryTracks> {
id: 'allTracks',
text: 'All offline tracks'.i18n,
source: 'offline'));
}, onHold: () {
MenuSheet(context).defaultTrackMenu(track);
}, onSecondary: (details) {
MenuSheet(context)
.defaultTrackMenu(track, details: details);
}),
],
)));
@ -689,11 +691,15 @@ class _LibraryAlbumsState extends State<LibraryAlbums> {
Navigator.of(context).pushRoute(
builder: (context) => AlbumDetails(a));
},
onHold: () async {
onSecondary: (details) {
MenuSheet m = MenuSheet(context);
m.defaultAlbumMenu(a, onRemove: () {
setState(() => _albums!.remove(a));
});
m.defaultAlbumMenu(
a,
details: details,
onRemove: () {
setState(() => _albums!.remove(a));
},
);
},
);
}),
@ -727,9 +733,10 @@ class _LibraryAlbumsState extends State<LibraryAlbums> {
Navigator.of(context).pushRoute(
builder: (context) => AlbumDetails(a));
},
onHold: () async {
onSecondary: (details) {
MenuSheet m = MenuSheet(context);
m.defaultAlbumMenu(a, onRemove: () {
m.defaultAlbumMenu(a, details: details,
onRemove: () {
setState(() {
albums.remove(a);
_albums!.remove(a);
@ -1090,10 +1097,10 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
Navigator.of(context).pushRoute(
builder: (context) => PlaylistDetails(favoritesPlaylist));
},
onHold: () {
onSecondary: (details) {
MenuSheet m = MenuSheet(context);
favoritesPlaylist.library = true;
m.defaultPlaylistMenu(favoritesPlaylist);
m.defaultPlaylistMenu(favoritesPlaylist, details: details);
},
),
@ -1104,9 +1111,9 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
p,
onTap: () => Navigator.of(context)
.pushRoute(builder: (context) => PlaylistDetails(p)),
onHold: () {
onSecondary: (details) {
MenuSheet m = MenuSheet(context);
m.defaultPlaylistMenu(p, onRemove: () {
m.defaultPlaylistMenu(p, details: details, onRemove: () {
setState(() => _playlists!.remove(p));
}, onUpdate: () {
_load();
@ -1140,9 +1147,10 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
p,
onTap: () => Navigator.of(context).pushRoute(
builder: (context) => PlaylistDetails(p)),
onHold: () {
onSecondary: (details) {
MenuSheet m = MenuSheet(context);
m.defaultPlaylistMenu(p, onRemove: () {
m.defaultPlaylistMenu(p, details: details,
onRemove: () {
setState(() {
playlists.remove(p);
_playlists!.remove(p);
@ -1196,7 +1204,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
itemCount: cache.history.length,
itemBuilder: (BuildContext context, int i) {
Track t = cache.history[cache.history.length - i - 1];
return TrackTile(
return TrackTile.fromTrack(
t,
onTap: () {
playerHelper.playFromTrackList(
@ -1205,9 +1213,9 @@ class _HistoryScreenState extends State<HistoryScreen> {
QueueSource(
id: null, text: 'History'.i18n, source: 'history'));
},
onHold: () {
onSecondary: (details) {
MenuSheet m = MenuSheet(context);
m.defaultTrackMenu(t);
m.defaultTrackMenu(t, details: details);
},
);
},

View File

@ -13,20 +13,45 @@ import 'package:freezer/ui/error.dart';
import 'package:freezer/ui/player_bar.dart';
import 'package:freezer/ui/player_screen.dart';
class LyricsScreen extends StatefulWidget {
const LyricsScreen({Key? key}) : super(key: key);
class LyricsScreen extends StatelessWidget {
const LyricsScreen({super.key});
@override
State<LyricsScreen> createState() => _LyricsScreenState();
Widget build(BuildContext context) {
return PlayerScreenBackground(
enabled: settings.playerBackgroundOnLyrics,
appBar: AppBar(
title: Text('Lyrics'.i18n),
systemOverlayStyle: PlayerScreenBackground.getSystemUiOverlayStyle(
context,
enabled: settings.playerBackgroundOnLyrics),
backgroundColor: Colors.transparent,
),
child: const Column(
children: [
LyricsWidget(),
Divider(height: 1.0, thickness: 1.0),
PlayerBar(backgroundColor: Colors.transparent),
],
));
}
}
class _LyricsScreenState extends State<LyricsScreen> {
class LyricsWidget extends StatefulWidget {
const LyricsWidget({Key? key}) : super(key: key);
@override
State<LyricsWidget> createState() => _LyricsWidgetState();
}
class _LyricsWidgetState extends State<LyricsWidget> {
late StreamSubscription _mediaItemSub;
late StreamSubscription _playbackStateSub;
int? _currentIndex = -1;
int? _prevIndex = -1;
final ScrollController _controller = ScrollController();
final double height = 90;
BoxConstraints? _widgetConstraints;
Lyrics? _lyrics;
bool _loading = true;
CancelableOperation<Lyrics>? _lyricsCancelable;
@ -60,7 +85,9 @@ class _LyricsScreenState extends State<LyricsScreen> {
_loading = false;
_lyrics = lyrics;
});
_scrollToLyric();
SchedulerBinding.instance.addPostFrameCallback(
(_) => _updatePosition(audioHandler.playbackState.value.position));
} catch (e) {
if (!mounted) return;
setState(() {
@ -72,8 +99,15 @@ class _LyricsScreenState extends State<LyricsScreen> {
Future<void> _scrollToLyric() async {
if (!_controller.hasClients) return;
//Lyric height, screen height, appbar height
double scrollTo = (height * _currentIndex!) -
(MediaQuery.of(context).size.height / 4 + height / 2);
double scrollTo;
if (_widgetConstraints == null) {
scrollTo = (height * _currentIndex!) -
(MediaQuery.of(context).size.height / 4 + height / 2);
} else {
final widgetHeight = _widgetConstraints!.maxHeight;
final minScroll = height * _currentIndex!;
scrollTo = minScroll - widgetHeight / 2 + height / 2;
}
print(
'${height * _currentIndex!}, ${MediaQuery.of(context).size.height / 2}');
@ -87,25 +121,27 @@ class _LyricsScreenState extends State<LyricsScreen> {
_animatedScroll = false;
}
void _updatePosition(Duration position) {
if (_loading) return;
if (!_syncedLyrics) 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(() {});
_prevIndex = _currentIndex;
if (_freeScroll) return;
_scrollToLyric();
}
@override
void initState() {
SchedulerBinding.instance.addPostFrameCallback((_) {
//Enable visualizer
// if (settings.lyricsVisualizer) playerHelper.startVisualizer();
_playbackStateSub = AudioService.position.listen((position) {
if (_loading) return;
if (!_syncedLyrics) 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(() {});
_prevIndex = _currentIndex;
if (_freeScroll) return;
_scrollToLyric();
});
_playbackStateSub = AudioService.position.listen(_updatePosition);
});
if (audioHandler.mediaItem.value != null) {
_loadForId(audioHandler.mediaItem.value!.id);
@ -130,145 +166,115 @@ class _LyricsScreenState extends State<LyricsScreen> {
super.dispose();
}
ScrollBehavior get _scrollBehavior {
if (_freeScroll) {
return ScrollConfiguration.of(context);
}
return ScrollConfiguration.of(context).copyWith(scrollbars: false);
}
@override
Widget build(BuildContext context) {
return PlayerScreenBackground(
enabled: settings.playerBackgroundOnLyrics,
appBar: AppBar(
title: Text('Lyrics'.i18n),
systemOverlayStyle: PlayerScreenBackground.getSystemUiOverlayStyle(
context,
enabled: settings.playerBackgroundOnLyrics),
backgroundColor: Colors.transparent,
),
child: Column(
children: [
if (_freeScroll && !_loading)
Center(
child: TextButton(
onPressed: () {
setState(() => _freeScroll = false);
_scrollToLyric();
},
style: ButtonStyle(
foregroundColor: MaterialStateProperty.all(Colors.white)),
child: Text(
_currentIndex! >= 0
? (_lyrics?.lyrics?[_currentIndex!].text ?? '...')
: '...',
textAlign: TextAlign.center,
)),
),
Expanded(
child: Stack(children: [
//Lyrics
_error != null
?
//Shouldn't really happen, empty lyrics have own text
ErrorScreen(message: _error.toString())
:
// Loading lyrics
_loading
? const Center(child: CircularProgressIndicator())
: NotificationListener<ScrollStartNotification>(
onNotification:
(ScrollStartNotification notification) {
if (!_syncedLyrics) return false;
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: const 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: _syncedLyrics ? height : null,
child: InkWell(
return Column(children: [
if (_freeScroll && !_loading)
Center(
child: TextButton(
onPressed: () {
setState(() => _freeScroll = false);
_scrollToLyric();
},
style: ButtonStyle(
foregroundColor: MaterialStateProperty.all(Colors.white)),
child: Text(
_currentIndex! >= 0
? (_lyrics?.lyrics?[_currentIndex!].text ?? '...')
: '...',
textAlign: TextAlign.center,
)),
),
Expanded(
child: _error != null
?
//Shouldn't really happen, empty lyrics have own text
ErrorScreen(message: _error.toString())
:
// Loading lyrics
_loading
? const Center(child: CircularProgressIndicator())
: LayoutBuilder(builder: (context, constraints) {
_widgetConstraints = constraints;
return NotificationListener<ScrollStartNotification>(
onNotification: (ScrollStartNotification notification) {
if (!_syncedLyrics) return false;
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: ScrollConfiguration(
behavior: _scrollBehavior,
child: ListView.builder(
controller: _controller,
itemCount: _lyrics!.lyrics!.length,
itemBuilder: (BuildContext context, int i) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0),
child: Container(
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(8.0),
onTap: _syncedLyrics &&
_lyrics!.id != null
? () => audioHandler.seek(
_lyrics!.lyrics![i].offset!)
: null,
child: Center(
child: Padding(
padding: _currentIndex == i
? EdgeInsets.zero
: const EdgeInsets.symmetric(
horizontal: 1.0),
child: Text(
_lyrics!.lyrics![i].text!,
textAlign: _syncedLyrics
? TextAlign.center
: TextAlign.start,
style: TextStyle(
fontSize: _syncedLyrics
? 26.0
: 20.0,
fontWeight:
(_currentIndex == i)
? FontWeight.bold
: FontWeight
.normal),
color: _currentIndex == i
? Colors.grey.withOpacity(0.25)
: Colors.transparent,
),
height: _syncedLyrics ? height : null,
child: InkWell(
borderRadius:
BorderRadius.circular(8.0),
onTap: _syncedLyrics &&
_lyrics!.id != null
? () => audioHandler.seek(
_lyrics!.lyrics![i].offset!)
: null,
child: Center(
child: Padding(
padding: _currentIndex == i
? EdgeInsets.zero
: const EdgeInsets
.symmetric(
horizontal: 1.0),
child: Text(
_lyrics!.lyrics![i].text!,
textAlign: _syncedLyrics
? TextAlign.center
: TextAlign.start,
style: TextStyle(
fontSize: _syncedLyrics
? 26.0
: 20.0,
fontWeight:
(_currentIndex == i)
? FontWeight.bold
: FontWeight
.normal),
),
),
),
))));
},
)),
//Visualizer
//if (settings.lyricsVisualizer)
// Positioned(
// bottom: 0,
// left: 0,
// right: 0,
// child: StreamBuilder(
// stream: playerHelper.visualizerStream,
// builder: (BuildContext context, AsyncSnapshot snapshot) {
// List<double> data = snapshot.data ?? [];
// double width = MediaQuery.of(context).size.width /
// data.length; //- 0.25;
// return Row(
// crossAxisAlignment: CrossAxisAlignment.end,
// children: List.generate(
// data.length,
// (i) => AnimatedContainer(
// duration: Duration(milliseconds: 130),
// color: settings.primaryColor,
// height: data[i] * 100,
// width: width,
// )),
// );
// }),
// ),
]),
),
const Divider(height: 1.0, thickness: 1.0),
const PlayerBar(backgroundColor: Colors.transparent),
],
))));
},
)));
}),
),
);
]);
}
}

View File

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:ffi';
import 'package:freezer/main.dart';
import 'package:freezer/ui/player_bar.dart';
@ -132,8 +133,9 @@ class MenuSheet {
backgroundColor: Colors.transparent,
context: context,
isScrollControlled: true,
enableDrag: true,
enableDrag: false,
showDragHandle: false,
elevation: 0.0,
builder: (BuildContext context) {
return DraggableScrollableSheet(
initialChildSize: 0.5,
@ -160,8 +162,12 @@ class MenuSheet {
}
//Default track options
void defaultTrackMenu(Track track,
{List<Widget> options = const [], Function? onRemove}) {
void defaultTrackMenu(
Track track, {
List<Widget> options = const [],
Function? onRemove,
TapDownDetails? details,
}) {
showWithTrack(track, [
addToQueueNext(track),
addToQueue(track),
@ -359,7 +365,9 @@ class MenuSheet {
//Default album options
void defaultAlbumMenu(Album album,
{List<Widget> options = const [], Function? onRemove}) {
{List<Widget> options = const [],
Function? onRemove,
TapDownDetails? details}) {
show([
album.library!
? removeAlbum(album, onRemove: onRemove)
@ -424,7 +432,9 @@ class MenuSheet {
//===================
void defaultArtistMenu(Artist artist,
{List<Widget> options = const [], Function? onRemove}) {
{List<Widget> options = const [],
Function? onRemove,
TapDownDetails? details}) {
show([
artist.library!
? removeArtist(artist, onRemove: onRemove)
@ -467,11 +477,13 @@ class MenuSheet {
void defaultPlaylistMenu(Playlist playlist,
{List<Widget> options = const [],
Function? onRemove,
Function? onUpdate}) {
Function? onUpdate,
TapDownDetails? details}) {
show([
playlist.library!
? removePlaylistLibrary(playlist, onRemove: onRemove)
: addPlaylistLibrary(playlist),
if (playlist.library != null)
playlist.library!
? removePlaylistLibrary(playlist, onRemove: onRemove)
: addPlaylistLibrary(playlist),
addPlaylistOffline(playlist),
downloadPlaylist(playlist),
shareTile('playlist', playlist.id),

View File

@ -198,7 +198,7 @@ class FancyScaffoldState extends State<FancyScaffold>
}
}
class PlayerBar extends StatefulWidget {
class PlayerBar extends StatelessWidget {
final VoidCallback? onTap;
final bool shouldHaveHero;
final Color? backgroundColor;
@ -211,14 +211,7 @@ class PlayerBar extends StatefulWidget {
this.focusNode,
}) : super(key: key);
@override
State<PlayerBar> createState() => _PlayerBarState();
}
class _PlayerBarState extends State<PlayerBar> {
final double iconSize = 28;
late StreamSubscription mediaItemSub;
late bool _isNothingPlaying = audioHandler.mediaItem.value == null;
double parsePosition(Duration position) {
if (audioHandler.mediaItem.value == null) return 0.0;
@ -229,120 +222,86 @@ class _PlayerBarState extends State<PlayerBar> {
audioHandler.mediaItem.value!.duration!.inSeconds;
}
@override
void initState() {
mediaItemSub = audioHandler.mediaItem.listen((playingItem) {
if ((playingItem == null && !_isNothingPlaying) ||
(playingItem != null && _isNothingPlaying)) {
setState(() => _isNothingPlaying = playingItem == null);
}
});
super.initState();
}
Color get backgroundColor =>
widget.backgroundColor ??
Theme.of(context).navigationBarTheme.backgroundColor ??
Theme.of(context).colorScheme.surface;
@override
void dispose() {
mediaItemSub.cancel();
super.dispose();
}
bool _gestureRegistered = false;
Color? get _backgroundColor => backgroundColor;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 68.0,
child: _isNothingPlaying
? null
: GestureDetector(
onHorizontalDragUpdate: (details) async {
if (_gestureRegistered) return;
const double sensitivity = 12.69;
//Right swipe
_gestureRegistered = true;
if (details.delta.dx > sensitivity) {
await audioHandler.skipToPrevious();
child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
Expanded(
child: StreamBuilder<MediaItem?>(
stream: audioHandler.mediaItem,
initialData: audioHandler.mediaItem.valueOrNull,
builder: (context, snapshot) {
if (snapshot.data == null) {
return Material(
child: ListTile(
leading: Image.asset('assets/cover_thumb.jpg'),
title: Text('Nothing is currently playing'.i18n),
),
);
}
//Left
if (details.delta.dx < -sensitivity) {
await audioHandler.skipToNext();
}
_gestureRegistered = false;
return;
},
child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
Expanded(
child: StreamBuilder<MediaItem?>(
stream: audioHandler.mediaItem,
initialData: audioHandler.mediaItem.valueOrNull,
builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox();
final currentMediaItem = snapshot.data!;
final image = CachedImage(
width: 50,
height: 50,
url: currentMediaItem.extras!['thumb'] ??
currentMediaItem.artUri.toString(),
);
final leadingWidget = widget.shouldHaveHero
? Hero(tag: currentMediaItem.id, child: image)
: image;
return Material(
child: ListTile(
dense: true,
focusNode: widget.focusNode,
contentPadding:
const EdgeInsets.symmetric(horizontal: 8.0),
onTap: widget.onTap,
leading: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: leadingWidget),
title: Text(
currentMediaItem.displayTitle!,
overflow: TextOverflow.clip,
maxLines: 1,
),
subtitle: Text(
currentMediaItem.displaySubtitle ?? '',
overflow: TextOverflow.clip,
maxLines: 1,
),
trailing: IconTheme(
data: IconThemeData(
color: settings.isDark
? Colors.white
: Colors.grey[600]),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
PrevNextButton(
iconSize,
prev: true,
),
PlayPauseButton(iconSize),
PrevNextButton(iconSize)
],
),
)));
}),
),
SizedBox(
height: 3.0,
child: StreamBuilder<Duration>(
stream: AudioService.position,
builder: (context, snapshot) {
return LinearProgressIndicator(
value: parsePosition(snapshot.data ?? Duration.zero),
);
}),
),
]),
),
final currentMediaItem = snapshot.data!;
final image = CachedImage(
width: 50,
height: 50,
url: currentMediaItem.extras!['thumb'] ??
currentMediaItem.artUri.toString(),
);
final leadingWidget = shouldHaveHero
? Hero(tag: currentMediaItem.id, child: image)
: image;
return Material(
child: ListTile(
tileColor: _backgroundColor,
focusNode: focusNode,
contentPadding:
const EdgeInsets.symmetric(horizontal: 8.0),
onTap: onTap,
leading: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: leadingWidget),
title: Text(
currentMediaItem.displayTitle!,
overflow: TextOverflow.clip,
maxLines: 1,
),
subtitle: Text(
currentMediaItem.displaySubtitle ?? '',
overflow: TextOverflow.clip,
maxLines: 1,
),
trailing: IconTheme(
data: IconThemeData(
color: settings.isDark
? Colors.white
: Colors.grey[600]),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
PrevNextButton(
iconSize,
prev: true,
),
PlayPauseButton(iconSize),
PrevNextButton(iconSize)
],
),
)));
}),
),
SizedBox(
height: 3.0,
child: StreamBuilder<Duration>(
stream: AudioService.position,
builder: (context, snapshot) {
return LinearProgressIndicator(
value: parsePosition(snapshot.data ?? Duration.zero),
);
}),
),
]),
);
}
}

View File

@ -1,6 +1,7 @@
import 'dart:ui';
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/services.dart';
@ -9,6 +10,7 @@ import 'package:freezer/api/cache.dart';
import 'package:freezer/api/deezer.dart';
import 'package:freezer/api/definitions.dart';
import 'package:freezer/api/player.dart';
import 'package:freezer/main.dart';
import 'package:freezer/page_routes/fade.dart';
import 'package:freezer/settings.dart';
import 'package:freezer/translations.i18n.dart';
@ -91,11 +93,13 @@ class PlayerScreen extends StatelessWidget {
return ChangeNotifierProvider(
create: (context) => BackgroundProvider(),
child: PlayerScreenBackground(
child: OrientationBuilder(
builder: (context, orientation) =>
orientation == Orientation.landscape
? const PlayerScreenHorizontal()
: const PlayerScreenVertical())),
child: MainScreen.of(context).isDesktop
? const PlayerScreenDesktop()
: OrientationBuilder(
builder: (context, orientation) =>
orientation == Orientation.landscape
? const PlayerScreenHorizontal()
: const PlayerScreenVertical())),
);
}
}
@ -287,8 +291,11 @@ class PlayerScreenVertical extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: PlayerTextSubtext(textSize: 64.sp),
),
SeekBar(textSize: 48.sp),
PlaybackControls(86.sp),
SeekBar(textSize: 38.sp),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: PlaybackControls(86.sp),
),
Padding(
padding:
const EdgeInsets.symmetric(vertical: 0, horizontal: 16.0),
@ -299,6 +306,98 @@ class PlayerScreenVertical extends StatelessWidget {
}
}
class PlayerScreenDesktop extends StatelessWidget {
const PlayerScreenDesktop({super.key});
@override
Widget build(BuildContext context) {
return Row(children: [
Flexible(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: PlayerScreenTopRow(
textSize: 10.sp,
iconSize: 17.sp,
showQueueButton: false,
),
),
ConstrainedBox(
constraints: BoxConstraints.loose(const Size.square(500)),
child: const BigAlbumArt()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: PlayerTextSubtext(textSize: 18.sp),
),
SeekBar(textSize: 12.sp),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: PlaybackControls(24.sp),
),
Padding(
padding:
const EdgeInsets.symmetric(vertical: 0, horizontal: 16.0),
child: BottomBarControls(
size: 16.sp,
showLyricsButton: false,
),
)
]),
),
),
const Expanded(
flex: 2,
child: Padding(
padding: EdgeInsets.only(left: 16.0, right: 16.0, top: 24.0),
child: _DesktopTabView(),
)),
]);
}
}
class _DesktopTabView extends StatelessWidget {
const _DesktopTabView({super.key});
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Column(children: [
TabBar(
tabs: [
Tab(
text: 'Queue'.i18n,
height: 48.0,
),
Tab(
text: 'Lyrics'.i18n,
),
],
labelStyle: Theme.of(context)
.textTheme
.labelLarge!
.copyWith(fontSize: 18.0)),
const Expanded(
child: SizedBox.expand(
child: Material(
type: MaterialType.transparency,
child: TabBarView(children: [
!kDebugMode
? Text('Queue view is disabled in Debug mode')
: QueueListWidget(),
LyricsWidget(),
]),
),
)),
]),
);
}
}
class FitOrScrollText extends StatefulWidget {
final String text;
final TextStyle style;
@ -741,23 +840,32 @@ class _BigAlbumArtState extends State<BigAlbumArt> {
// },
onTap: () => Navigator.push(
context,
PageRouteBuilder(
opaque: false, // transparent background
barrierDismissible: true,
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: const BoxDecoration(
color: Color.fromARGB(0x90, 0, 0, 0))),
);
})),
FadePageRoute(
barrierDismissible: true,
builder: (context) {
final mediaItem = audioHandler.mediaItem.value!;
return ZoomableImageRoute(
imageUrl: mediaItem.artUri.toString(), heroKey: mediaItem.id);
},
)
// PageRouteBuilder(
// opaque: false, // transparent background
// barrierDismissible: true,
// 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: const BoxDecoration(
// color: Color.fromARGB(0x90, 0, 0, 0))),
// );
// }),
),
onHorizontalDragDown: (_) => _userScroll = true,
// delayed a bit, so to make sure that the page view updated.
onHorizontalDragEnd: (_) => Future.delayed(
@ -826,12 +934,14 @@ class PlayerScreenTopRow extends StatelessWidget {
final double? iconSize;
final double? textWidth;
final bool short;
final bool showQueueButton; // not needed on desktop
const PlayerScreenTopRow(
{super.key,
this.textSize,
this.iconSize,
this.textWidth,
this.short = false});
this.short = false,
this.showQueueButton = true});
@override
Widget build(BuildContext context) {
@ -867,16 +977,18 @@ class PlayerScreenTopRow extends StatelessWidget {
TextSpan(text: playerHelper.queueSource!.text ?? '')
], style: TextStyle(fontSize: textSize ?? 38.sp))),
),
IconButton(
icon: Icon(
Icons.menu,
semanticLabel: "Queue".i18n,
),
iconSize: size,
splashRadius: size * 1.5,
onPressed: () => Navigator.of(context)
.pushRoute(builder: (context) => const QueueScreen()),
),
showQueueButton
? IconButton(
icon: Icon(
Icons.menu,
semanticLabel: "Queue".i18n,
),
iconSize: size,
splashRadius: size * 1.5,
onPressed: () => Navigator.of(context)
.pushRoute(builder: (context) => const QueueScreen()),
)
: SizedBox.square(dimension: size + 16.0),
],
);
}
@ -997,7 +1109,13 @@ class _SeekBarState extends State<SeekBar> {
class BottomBarControls extends StatelessWidget {
final double size;
const BottomBarControls({Key? key, required this.size}) : super(key: key);
final bool
showLyricsButton; // removed in desktop mode, because there's a tabbed view which includes it
const BottomBarControls({
super.key,
required this.size,
this.showLyricsButton = true,
});
@override
Widget build(BuildContext context) {
@ -1018,13 +1136,15 @@ class BottomBarControls extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
IconButton(
iconSize: size,
icon: Icon(
Icons.subtitles,
semanticLabel: "Lyrics".i18n,
),
onPressed: () => _pushLyrics(context)),
QualityInfoWidget(textSize: size * 0.75),
if (showLyricsButton)
IconButton(
iconSize: size,
icon: Icon(
Icons.subtitles,
semanticLabel: "Lyrics".i18n,
),
onPressed: () => _pushLyrics(context)),
IconButton(
icon: Icon(
Icons.sentiment_very_dissatisfied,
@ -1055,7 +1175,6 @@ class BottomBarControls extends StatelessWidget {
// toastLength: Toast.LENGTH_SHORT);
// },
// ),
QualityInfoWidget(textSize: size * 0.75),
FavoriteButton(size: size * 0.85),
PlayerMenuButton(size: size)
],

View File

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:math';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -8,15 +9,50 @@ import 'package:freezer/translations.i18n.dart';
import 'package:freezer/ui/menu.dart';
import 'package:freezer/ui/tiles.dart';
class QueueScreen extends StatefulWidget {
class QueueScreen extends StatelessWidget {
const QueueScreen({super.key});
@override
State<QueueScreen> createState() => _QueueScreenState();
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Queue'.i18n),
systemOverlayStyle: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: Theme.of(context).scaffoldBackgroundColor,
systemNavigationBarDividerColor: Color(
Theme.of(context).scaffoldBackgroundColor.value - 0x00111111),
systemNavigationBarIconBrightness: Brightness.light,
),
// actions: <Widget>[
// IconButton(
// icon: Icon(
// Icons.shuffle,
// semanticLabel: "Shuffle".i18n,
// ),
// onPressed: () async {
// await playerHelper.toggleShuffle();
// setState(() {});
// },
// )
// ],
),
body: const SafeArea(child: QueueListWidget(shouldPopOnTap: true)));
}
}
class _QueueScreenState extends State<QueueScreen> {
static const itemExtent = 72.0; // height of each TrackTile
class QueueListWidget extends StatefulWidget {
final bool shouldPopOnTap;
const QueueListWidget({super.key, this.shouldPopOnTap = false});
@override
State<QueueListWidget> createState() => _QueueListWidgetState();
}
class _QueueListWidgetState extends State<QueueListWidget> {
static const itemExtent = 68.0; // height of each TrackTile
final _scrollController = ScrollController();
late StreamSubscription _queueSub;
static const _dismissibleBackground = DecoratedBox(
@ -55,7 +91,9 @@ class _QueueScreenState extends State<QueueScreen> {
});
WidgetsBinding.instance.addPostFrameCallback((_) {
// calculate position of current item
final position = playerHelper.queueIndex * itemExtent;
double position = min(playerHelper.queueIndex * itemExtent,
_scrollController.position.maxScrollExtent);
_scrollController.jumpTo(position);
});
super.initState();
@ -69,98 +107,74 @@ class _QueueScreenState extends State<QueueScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Queue'.i18n),
systemOverlayStyle: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: Theme.of(context).scaffoldBackgroundColor,
systemNavigationBarDividerColor: Color(
Theme.of(context).scaffoldBackgroundColor.value - 0x00111111),
systemNavigationBarIconBrightness: Brightness.light,
),
// actions: <Widget>[
// IconButton(
// icon: Icon(
// Icons.shuffle,
// semanticLabel: "Shuffle".i18n,
// ),
// onPressed: () async {
// await playerHelper.toggleShuffle();
// setState(() {});
// },
// )
// ],
),
body: SafeArea(
child: ReorderableListView.builder(
buildDefaultDragHandles: false,
scrollController: _scrollController,
// specify the itemExtent normally and remove it when reordering because of an issue with [SliverFixedExtentList]
// https://github.com/flutter/flutter/issues/84901
itemExtent: _isReordering ? null : itemExtent,
onReorderStart: (_) => setState(() => _isReordering = true),
onReorderEnd: (_) => setState(() => _isReordering = true),
onReorder: (oldIndex, newIndex) {
setState(() => _queueCache.reorder(oldIndex, newIndex));
if (oldIndex == playerHelper.queueIndex) {
audioHandler.customAction('setIndex', {'index': newIndex});
}
playerHelper.reorder(oldIndex, newIndex);
return ReorderableListView.builder(
buildDefaultDragHandles: false,
scrollController: _scrollController,
// specify the itemExtent normally and remove it when reordering because of an issue with [SliverFixedExtentList]
// https://github.com/flutter/flutter/issues/84901
itemExtent: _isReordering ? null : itemExtent,
onReorderStart: (_) => setState(() => _isReordering = true),
onReorderEnd: (_) => setState(() => _isReordering = true),
onReorder: (oldIndex, newIndex) {
setState(() => _queueCache.reorder(oldIndex, newIndex));
if (oldIndex == playerHelper.queueIndex) {
audioHandler.customAction('setIndex', {'index': newIndex});
}
playerHelper.reorder(oldIndex, newIndex);
},
itemCount: _queueCache.length,
itemBuilder: (BuildContext context, int index) {
final mediaItem = _queueCache[index];
final int itemId = mediaItem.extras!['id'] ?? 0;
return Dismissible(
key: ValueKey(mediaItem.id.hashCode | itemId),
background: _dismissibleBackground,
secondaryBackground: _dismissibleSecondaryBackground,
onDismissed: (_) {
audioHandler.removeQueueItemAt(index).then((value) {
if (index == playerHelper.queueIndex) {
audioHandler.skipToNext();
}
});
setState(() => _queueCache.removeAt(index));
},
itemCount: _queueCache.length,
itemBuilder: (BuildContext context, int index) {
Track track = Track.fromMediaItem(_queueCache[index]);
final int itemId = _queueCache[index].extras!['id'] ?? 0;
return Dismissible(
key: ValueKey(track.id.hashCode ^ itemId),
background: _dismissibleBackground,
secondaryBackground: _dismissibleSecondaryBackground,
onDismissed: (_) {
audioHandler.removeQueueItemAt(index).then((value) {
if (index == playerHelper.queueIndex) {
audioHandler.skipToNext();
confirmDismiss: (_) {
final completer = Completer<bool>();
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(
behavior: SnackBarBehavior.floating,
content: Text('Song deleted from queue'.i18n),
action: SnackBarAction(
label: 'UNDO'.i18n,
onPressed: () => completer.complete(false))))
.closed
.then((value) {
if (value == SnackBarClosedReason.action) return;
completer.complete(true);
});
return completer.future;
},
child: SizedBox(
height: itemExtent,
child: TrackTile.fromMediaItem(
mediaItem,
trailing: ReorderableDragStartListener(
index: index, child: const Icon(Icons.drag_handle)),
onTap: () {
audioHandler.skipToQueueItem(index).then((value) {
if (widget.shouldPopOnTap) {
Navigator.of(context).pop();
}
});
setState(() => _queueCache.removeAt(index));
},
confirmDismiss: (_) {
final completer = Completer<bool>();
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(
behavior: SnackBarBehavior.floating,
content: Text('Song deleted from queue'.i18n),
action: SnackBarAction(
label: 'UNDO'.i18n,
onPressed: () => completer.complete(false))))
.closed
.then((value) {
if (value == SnackBarClosedReason.action) return;
completer.complete(true);
});
return completer.future;
},
child: SizedBox(
height: itemExtent,
child: TrackTile(
track,
trailing: ReorderableDragStartListener(
index: index, child: const Icon(Icons.drag_handle)),
onTap: () {
audioHandler.skipToQueueItem(index).then((value) {
Navigator.of(context).pop();
});
},
onHold: () => MenuSheet(context).defaultTrackMenu(track),
),
),
);
},
),
),
onSecondary: (_) => MenuSheet(context)
.defaultTrackMenu(Track.fromMediaItem(mediaItem)),
checkTrackOffline: false,
),
),
);
},
);
}
}

View File

@ -277,7 +277,7 @@ class _SearchScreenState extends State<SearchScreen> {
final data = cache.searchHistory[i].data;
switch (cache.searchHistory[i].type) {
case SearchHistoryItemType.track:
return TrackTile(
return TrackTile.fromTrack(
data,
onTap: () {
List<Track?> queue = cache.searchHistory
@ -293,8 +293,8 @@ class _SearchScreenState extends State<SearchScreen> {
source: 'searchhistory',
id: 'searchhistory'));
},
onHold: () =>
MenuSheet(context).defaultTrackMenu(data),
onSecondary: (details) => MenuSheet(context)
.defaultTrackMenu(data, details: details),
trailing: _removeHistoryItemWidget(i),
);
case SearchHistoryItemType.album:
@ -304,8 +304,8 @@ class _SearchScreenState extends State<SearchScreen> {
Navigator.of(context).pushRoute(
builder: (context) => AlbumDetails(data));
},
onHold: () =>
MenuSheet(context).defaultAlbumMenu(data),
onSecondary: (details) => MenuSheet(context)
.defaultAlbumMenu(data, details: details),
trailing: _removeHistoryItemWidget(i),
);
case SearchHistoryItemType.artist:
@ -328,8 +328,9 @@ class _SearchScreenState extends State<SearchScreen> {
builder: (context) =>
PlaylistDetails(data));
},
onHold: () => MenuSheet(context)
.defaultPlaylistMenu(data),
onSecondary: (details) => MenuSheet(context)
.defaultPlaylistMenu(data,
details: details),
trailing: _removeHistoryItemWidget(i),
);
default:
@ -477,7 +478,7 @@ class SearchResultsScreen extends StatelessWidget {
),
for (final track in results.tracks!
.getRange(0, min(results.tracks!.length, 3)))
TrackTile(track, onTap: () {
TrackTile.fromTrack(track, onTap: () {
cache.addToSearchHistory(track);
playerHelper.playFromTrackList(
results.tracks!,
@ -486,9 +487,9 @@ class SearchResultsScreen extends StatelessWidget {
text: 'Search'.i18n,
id: query,
source: 'search'));
}, onHold: () {
}, onSecondary: (details) {
MenuSheet m = MenuSheet(context);
m.defaultTrackMenu(track);
m.defaultTrackMenu(track, details: details);
}),
ListTile(
title: Text('Show all tracks'.i18n),
@ -518,9 +519,9 @@ class SearchResultsScreen extends StatelessWidget {
),
for (final album in results.albums!
.getRange(0, min(results.albums!.length, 3)))
AlbumTile(album, onHold: () {
AlbumTile(album, onSecondary: (details) {
MenuSheet m = MenuSheet(context);
m.defaultAlbumMenu(album);
m.defaultAlbumMenu(album, details: details);
}, onTap: () {
cache.addToSearchHistory(album);
Navigator.of(context)
@ -560,9 +561,9 @@ class SearchResultsScreen extends StatelessWidget {
Navigator.of(context).pushRoute(
builder: (context) => ArtistDetails(artist));
},
onHold: () {
onSecondary: (details) {
MenuSheet m = MenuSheet(context);
m.defaultArtistMenu(artist);
m.defaultArtistMenu(artist, details: details);
},
),
])),
@ -590,9 +591,9 @@ class SearchResultsScreen extends StatelessWidget {
Navigator.of(context).pushRoute(
builder: (context) => PlaylistDetails(playlist));
},
onHold: () {
onSecondary: (details) {
MenuSheet m = MenuSheet(context);
m.defaultPlaylistMenu(playlist);
m.defaultPlaylistMenu(playlist, details: details);
},
),
ListTile(
@ -702,14 +703,14 @@ class TrackListScreen extends StatelessWidget {
itemCount: tracks!.length,
itemBuilder: (BuildContext context, int i) {
Track t = tracks![i]!;
return TrackTile(
return TrackTile.fromTrack(
t,
onTap: () {
playerHelper.playFromTrackList(tracks!, t.id, queueSource);
},
onHold: () {
onSecondary: (details) {
MenuSheet m = MenuSheet(context);
m.defaultTrackMenu(t);
m.defaultTrackMenu(t, details: details);
},
);
},
@ -737,9 +738,9 @@ class AlbumListScreen extends StatelessWidget {
Navigator.of(context)
.pushRoute(builder: (context) => AlbumDetails(a));
},
onHold: () {
onSecondary: (details) {
MenuSheet m = MenuSheet(context);
m.defaultAlbumMenu(a!);
m.defaultAlbumMenu(a!, details: details);
},
);
},
@ -766,9 +767,9 @@ class SearchResultPlaylists extends StatelessWidget {
Navigator.of(context)
.pushRoute(builder: (context) => PlaylistDetails(p));
},
onHold: () {
onSecondary: (details) {
MenuSheet m = MenuSheet(context);
m.defaultPlaylistMenu(p!);
m.defaultPlaylistMenu(p!, details: details);
},
);
},

View File

@ -1,5 +1,8 @@
import 'dart:io';
import 'package:country_pickers/country.dart';
import 'package:country_pickers/country_picker_dialog.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_displaymode/flutter_displaymode.dart';
@ -144,6 +147,18 @@ class AppearanceSettings extends StatefulWidget {
class _AppearanceSettingsState extends State<AppearanceSettings> {
ColorSwatch<dynamic> _swatch(int c) => ColorSwatch(c, {500: Color(c)});
String _navigationRailAppearanceToString(
NavigationRailAppearance navigationRailAppearance) {
switch (navigationRailAppearance) {
case NavigationRailAppearance.always_expanded:
return 'Always expanded'.i18n;
case NavigationRailAppearance.expand_on_hover:
return 'Expand on hover'.i18n;
case NavigationRailAppearance.icons_only:
return 'Icons only'.i18n;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -411,35 +426,58 @@ class _AppearanceSettingsState extends State<AppearanceSettings> {
value: settings.useArtColor,
onChanged: (v) => setState(() => settings.updateUseArtColor(v)),
),
//Display mode
ListTile(
leading: const Icon(Icons.screen_lock_portrait),
title: Text('Change display mode'.i18n),
subtitle: Text('Enable high refresh rates'.i18n),
onTap: () async {
final modes = await FlutterDisplayMode.supported;
// ignore: use_build_context_synchronously
showDialog(
if (MainScreen.of(context).isDesktop)
ListTile(
leading: const Icon(Icons.view_sidebar),
title: Text('Navigation rail appearance'.i18n),
subtitle: Text(
'${'Currently'.i18n}: ${_navigationRailAppearanceToString(settings.navigationRailAppearance)}'),
onTap: () => showDialog(
context: context,
builder: (context) {
return SimpleDialog(
title: Text('Display mode'.i18n),
children: List.generate(
modes.length,
(i) => SimpleDialogOption(
child: Text(modes[i].toString()),
onPressed: () async {
final navigator = Navigator.of(context);
settings.displayMode = i;
await settings.save();
await FlutterDisplayMode.setPreferredMode(
modes[i]);
navigator.pop();
},
)));
});
},
)
builder: (context) => SimpleDialog(
title: Text('Navigation rail appearance'.i18n),
children: NavigationRailAppearance.values
.map((value) => SimpleDialogOption(
child: Text(
_navigationRailAppearanceToString(value)),
onPressed: () {
settings.navigationRailAppearance = value;
Navigator.pop(context);
settings.save().then((_) => updateTheme());
}))
.toList(growable: false),
)),
),
//Display mode (Android only!)
if (defaultTargetPlatform == TargetPlatform.android)
ListTile(
leading: const Icon(Icons.screen_lock_portrait),
title: Text('Change display mode'.i18n),
subtitle: Text('Enable high refresh rates'.i18n),
onTap: () async {
final modes = await FlutterDisplayMode.supported;
// ignore: use_build_context_synchronously
showDialog(
context: context,
builder: (context) {
return SimpleDialog(
title: Text('Display mode'.i18n),
children: List.generate(
modes.length,
(i) => SimpleDialogOption(
child: Text(modes[i].toString()),
onPressed: () async {
final navigator = Navigator.of(context);
settings.displayMode = i;
await settings.save();
await FlutterDisplayMode.setPreferredMode(
modes[i]);
navigator.pop();
},
)));
});
},
)
],
),
);

View File

@ -12,85 +12,164 @@ import 'cached_image.dart';
import 'dart:async';
class TrackTile extends StatelessWidget {
final Track track;
final void Function()? onTap;
final void Function()? onHold;
final Widget? trailing;
typedef SecondaryTapCallback = void Function(TapDownDetails?);
const TrackTile(this.track,
{this.onTap, this.onHold, this.trailing, Key? key})
: super(key: key);
class WrapSecondaryAction extends StatelessWidget {
final SecondaryTapCallback? onSecondaryTapDown;
final Widget child;
const WrapSecondaryAction(
{super.key, this.onSecondaryTapDown, required this.child});
@override
Widget build(BuildContext context) {
return ListTile(
title: StreamBuilder<MediaItem?>(
stream: audioHandler.mediaItem,
builder: (context, snapshot) {
final bool isHighlighted;
final mediaItem = snapshot.data;
if (!snapshot.hasData || snapshot.data == null) {
isHighlighted = false;
} else {
isHighlighted = mediaItem!.id == track.id;
}
return Text(
track.title!,
maxLines: 1,
overflow: TextOverflow.clip,
style: TextStyle(
color: isHighlighted
? Theme.of(context).colorScheme.primary
: null),
);
}),
subtitle: Text(
track.artistString,
maxLines: 1,
),
leading: CachedImage(
url: track.albumArt!.thumb,
width: 48.0,
height: 48.0,
),
onTap: onTap,
onLongPress: onHold,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
FutureBuilder<bool>(
future: downloadManager.checkOffline(track: track),
builder: (context, snapshot) {
if (snapshot.data == true) {
return const Padding(
padding: EdgeInsets.symmetric(horizontal: 2.0),
child: Icon(
Octicons.primitive_dot,
color: Colors.green,
size: 12.0,
),
);
}
return const SizedBox.shrink();
}),
if (track.explicit ?? false)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 2.0),
return GestureDetector(
onSecondaryTapDown: onSecondaryTapDown,
child: child,
);
}
}
VoidCallback? normalizeSecondary(SecondaryTapCallback? callback) {
if (callback == null) return null;
return () => callback.call(null);
}
class TrackTile extends StatelessWidget {
final VoidCallback? onTap;
/// Hold or Right Click
final SecondaryTapCallback? onSecondary;
final Widget? trailing;
final String trackId;
final String title;
final String artist;
final String artUri;
final bool explicit;
final String durationString;
/// Disable if not needed, makes app lag, and uses lots of resources
final bool checkTrackOffline;
const TrackTile({
required this.trackId,
required this.title,
required this.artist,
required this.artUri,
required this.explicit,
required this.durationString,
this.onTap,
this.onSecondary,
this.trailing,
this.checkTrackOffline = true,
Key? key,
}) : super(key: key);
factory TrackTile.fromTrack(Track track,
{VoidCallback? onTap,
SecondaryTapCallback? onSecondary,
Widget? trailing,
bool checkTrackOffline = true}) =>
TrackTile(
trackId: track.id,
title: track.title!,
artist: track.artistString,
artUri: track.albumArt!.thumb,
explicit: track.explicit!,
durationString: track.durationString,
onSecondary: onSecondary,
onTap: onTap,
trailing: trailing,
checkTrackOffline: checkTrackOffline,
);
factory TrackTile.fromMediaItem(MediaItem mediaItem,
{VoidCallback? onTap,
SecondaryTapCallback? onSecondary,
Widget? trailing,
bool checkTrackOffline = true}) =>
TrackTile(
trackId: mediaItem.id,
title: mediaItem.title,
artist: mediaItem.artist!,
artUri: mediaItem.extras!['thumb'],
explicit: false,
durationString: Track.durationAsString(mediaItem.duration!),
onSecondary: onSecondary,
onTap: onTap,
trailing: trailing,
checkTrackOffline: checkTrackOffline,
);
@override
Widget build(BuildContext context) {
return WrapSecondaryAction(
onSecondaryTapDown: onSecondary,
child: ListTile(
title: StreamBuilder<MediaItem?>(
stream: audioHandler.mediaItem,
builder: (context, snapshot) {
final mediaItem = snapshot.data;
final bool isHighlighted = mediaItem?.id == trackId;
return Text(
title,
maxLines: 1,
overflow: TextOverflow.clip,
style: TextStyle(
color: isHighlighted
? Theme.of(context).colorScheme.primary
: null),
);
}),
subtitle: Text(
artist,
maxLines: 1,
),
leading: CachedImage(
url: artUri,
width: 48.0,
height: 48.0,
),
onTap: onTap,
onLongPress: normalizeSecondary(onSecondary),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (checkTrackOffline)
FutureBuilder<bool>(
future:
downloadManager.checkOffline(track: Track(id: trackId)),
builder: (context, snapshot) {
if (snapshot.data == true) {
return const Padding(
padding: EdgeInsets.symmetric(horizontal: 2.0),
child: Icon(
Octicons.primitive_dot,
color: Colors.green,
size: 12.0,
),
);
}
return const SizedBox.shrink();
}),
if (explicit)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 2.0),
child: Text(
'E',
style: TextStyle(color: Colors.red),
),
),
SizedBox(
width: 42.0,
child: Text(
'E',
style: TextStyle(color: Colors.red),
durationString,
textAlign: TextAlign.center,
),
),
SizedBox(
width: 42.0,
child: Text(
track.durationString,
textAlign: TextAlign.center,
),
),
if (trailing != null) trailing!
],
if (trailing != null) trailing!
],
),
),
);
}
@ -99,30 +178,35 @@ class TrackTile extends StatelessWidget {
class AlbumTile extends StatelessWidget {
final Album? album;
final void Function()? onTap;
final void Function()? onHold;
/// Hold or Right click
final SecondaryTapCallback? onSecondary;
final Widget? trailing;
const AlbumTile(this.album,
{super.key, this.onTap, this.onHold, this.trailing});
{super.key, this.onTap, this.onSecondary, this.trailing});
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(
album!.title!,
maxLines: 1,
return WrapSecondaryAction(
onSecondaryTapDown: onSecondary,
child: ListTile(
title: Text(
album!.title!,
maxLines: 1,
),
subtitle: Text(
album!.artistString,
maxLines: 1,
),
leading: CachedImage(
url: album!.art!.thumb,
width: 48,
),
onTap: onTap,
onLongPress: normalizeSecondary(onSecondary),
trailing: trailing,
),
subtitle: Text(
album!.artistString,
maxLines: 1,
),
leading: CachedImage(
url: album!.art!.thumb,
width: 48,
),
onTap: onTap,
onLongPress: onHold,
trailing: trailing,
);
}
}
@ -130,16 +214,19 @@ class AlbumTile extends StatelessWidget {
class ArtistTile extends StatelessWidget {
final Artist? artist;
final void Function()? onTap;
final void Function()? onHold;
const ArtistTile(this.artist, {super.key, this.onTap, this.onHold});
/// Hold or Right click
final SecondaryTapCallback? onSecondary;
const ArtistTile(this.artist, {super.key, this.onTap, this.onSecondary});
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
onTap: onTap,
onLongPress: onHold,
onLongPress: normalizeSecondary(onSecondary),
onSecondaryTapDown: onSecondary,
child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
const SizedBox(height: 4),
CachedImage(
@ -163,11 +250,11 @@ class ArtistTile extends StatelessWidget {
class PlaylistTile extends StatelessWidget {
final Playlist? playlist;
final void Function()? onTap;
final void Function()? onHold;
final SecondaryTapCallback? onSecondary;
final Widget? trailing;
const PlaylistTile(this.playlist,
{super.key, this.onHold, this.onTap, this.trailing});
{super.key, this.onSecondary, this.onTap, this.trailing});
String? get subtitle {
if (playlist!.user == null ||
@ -182,22 +269,25 @@ class PlaylistTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(
playlist!.title!,
maxLines: 1,
return WrapSecondaryAction(
onSecondaryTapDown: onSecondary,
child: ListTile(
title: Text(
playlist!.title!,
maxLines: 1,
),
subtitle: Text(
subtitle!,
maxLines: 1,
),
leading: CachedImage(
url: playlist!.image!.thumb,
width: 48,
),
onTap: onTap,
onLongPress: normalizeSecondary(onSecondary),
trailing: trailing,
),
subtitle: Text(
subtitle!,
maxLines: 1,
),
leading: CachedImage(
url: playlist!.image!.thumb,
width: 48,
),
onTap: onTap,
onLongPress: onHold,
trailing: trailing,
);
}
}

View File

@ -57,6 +57,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.18.12"
audio_service_mpris:
dependency: "direct main"
description:
name: audio_service_mpris
sha256: "31be5de2db0c71b217157afce1974ac6d0ad329bd91deb1f19ad094d29340d8e"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
audio_service_platform_interface:
dependency: transitive
description:

View File

@ -93,6 +93,7 @@ dependencies:
isar: ^3.1.0+1
isar_flutter_libs: ^3.1.0+1
flutter_background_service: ^5.0.1
audio_service_mpris: ^0.1.0
#deezcryptor:
#path: deezcryptor/