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 { buildTypes {
release { release {
// TODO: Put back signingConfig.release signingConfig signingConfigs.release
signingConfig signingConfigs.debug
shrinkResources false shrinkResources false
minifyEnabled true minifyEnabled true
} }

View file

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

View file

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

View file

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

View file

@ -354,6 +354,8 @@ class AudioPlayerTaskInitArguments {
} }
class AudioPlayerTask extends BaseAudioHandler { class AudioPlayerTask extends BaseAudioHandler {
final _logger = Logger('AudioPlayerTask');
late AudioPlayer _player; late AudioPlayer _player;
late ConcatenatingAudioSource _audioSource; late ConcatenatingAudioSource _audioSource;
late DeezerAPI _deezerAPI; late DeezerAPI _deezerAPI;
@ -376,6 +378,7 @@ class AudioPlayerTask extends BaseAudioHandler {
StreamSubscription? _bufferPositionSubscription; StreamSubscription? _bufferPositionSubscription;
StreamSubscription? _audioSessionSubscription; StreamSubscription? _audioSessionSubscription;
StreamSubscription? _visualizerSubscription; StreamSubscription? _visualizerSubscription;
StreamSubscription? _connectivitySubscription;
/// Android Auto helper class for navigation /// Android Auto helper class for navigation
late final AndroidAuto _androidAuto; late final AndroidAuto _androidAuto;
@ -384,6 +387,8 @@ class AudioPlayerTask extends BaseAudioHandler {
AudioQuality mobileQuality = AudioQuality.MP3_128; AudioQuality mobileQuality = AudioQuality.MP3_128;
AudioQuality wifiQuality = AudioQuality.MP3_128; AudioQuality wifiQuality = AudioQuality.MP3_128;
AudioQuality _currentQuality = AudioQuality.MP3_128;
/// Current queueSource (=> where playback has begun from) /// Current queueSource (=> where playback has begun from)
QueueSource? queueSource; QueueSource? queueSource;
@ -466,7 +471,7 @@ class AudioPlayerTask extends BaseAudioHandler {
//Update //Update
_broadcastState(); _broadcastState();
}, onError: (Object e, StackTrace st) { }, onError: (Object e, StackTrace st) {
print('A stream error occurred: $e'); _logger.severe('A stream error occurred: $e');
}); });
_player.processingStateStream.listen((state) { _player.processingStateStream.listen((state) {
switch (state) { switch (state) {
@ -496,6 +501,20 @@ class AudioPlayerTask extends BaseAudioHandler {
//Load queue //Load queue
// queue.add(_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(); await _loadQueueFile();
if (initArgs.lastFMUsername != null && initArgs.lastFMPassword != null) { if (initArgs.lastFMUsername != null && initArgs.lastFMPassword != null) {
@ -506,6 +525,21 @@ class AudioPlayerTask extends BaseAudioHandler {
customEvent.add({'action': 'onLoad'}); 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 @override
Future skipToQueueItem(int index) async { Future skipToQueueItem(int index) async {
_lastPosition = null; _lastPosition = null;
@ -772,7 +806,9 @@ class AudioPlayerTask extends BaseAudioHandler {
if (!queue.hasValue || queue.value.isEmpty) { if (!queue.hasValue || queue.value.isEmpty) {
return; return;
} }
final sources = await Future.wait(queue.value.map(_mediaItemToAudioSource));
final sources =
await Future.wait(queue.value.map((e) => _mediaItemToAudioSource(e)));
_audioSource = ConcatenatingAudioSource( _audioSource = ConcatenatingAudioSource(
children: sources, children: sources,
@ -827,10 +863,6 @@ class AudioPlayerTask extends BaseAudioHandler {
//Due to current limitations of just_audio, quality fallback moved to DeezerDataSource in ExoPlayer //Due to current limitations of just_audio, quality fallback moved to DeezerDataSource in ExoPlayer
//This just returns fake url that contains metadata //This just returns fake url that contains metadata
List? playbackDetails = jsonDecode(mediaItem.extras!['playbackDetails']); 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) { if ((playbackDetails ?? []).length < 2) {
throw Exception('not enough playback details'); throw Exception('not enough playback details');
@ -848,7 +880,7 @@ class AudioPlayerTask extends BaseAudioHandler {
// DON'T use the java backend anymore, useless. // DON'T use the java backend anymore, useless.
return DeezerAudioSource( return DeezerAudioSource(
quality: quality, getQuality: () => _currentQuality,
trackId: mediaItem.id, trackId: mediaItem.id,
md5origin: playbackDetails![0], md5origin: playbackDetails![0],
mediaVersion: playbackDetails[1], mediaVersion: playbackDetails[1],

View file

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

View file

@ -166,6 +166,10 @@ class Settings {
@HiveField(47, defaultValue: false) @HiveField(47, defaultValue: false)
bool seekAsSkip = false; bool seekAsSkip = false;
@HiveField(48, defaultValue: NavigationRailAppearance.expand_on_hover)
NavigationRailAppearance navigationRailAppearance =
NavigationRailAppearance.expand_on_hover;
static LazyBox<Settings>? __box; static LazyBox<Settings>? __box;
static Future<LazyBox<Settings>> get _box async => static Future<LazyBox<Settings>> get _box async =>
__box ??= await Hive.openLazyBox<Settings>('settings'); __box ??= await Hive.openLazyBox<Settings>('settings');
@ -415,3 +419,13 @@ class SpotifyCredentialsSave {
_$SpotifyCredentialsSaveFromJson(json); _$SpotifyCredentialsSaveFromJson(json);
Map<String, dynamic> toJson() => _$SpotifyCredentialsSaveToJson(this); 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/material.dart';
import 'package:flutter/services.dart';
import 'package:freezer/page_routes/fade.dart';
import 'package:palette_generator/palette_generator.dart'; import 'package:palette_generator/palette_generator.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:photo_view/photo_view.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 String url;
final bool rounded; final bool rounded;
final double? width; final double? width;
final bool enableHero; final bool enableHero;
final Object? heroTag; final Object? heroTag;
const ZoomableImage({ ZoomableImage({
super.key, super.key,
required this.url, required this.url,
this.rounded = false, this.rounded = false,
@ -119,38 +121,15 @@ class ZoomableImage extends StatefulWidget {
this.heroTag, this.heroTag,
}); });
@override late final Object? _key = enableHero ? (heroTag ?? UniqueKey()) : null;
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
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
print('key: $_key'); print('key: $_key');
final image = CachedImage( final image = CachedImage(
url: widget.url, url: url,
rounded: widget.rounded, rounded: rounded,
width: widget.width, width: width,
fullThumb: true, fullThumb: true,
); );
final child = _key != null final child = _key != null
@ -165,25 +144,72 @@ class _ZoomableImageState extends State<ZoomableImage> {
child: child, child: child,
), ),
onTap: () { onTap: () {
Navigator.of(context).push(PageRouteBuilder( Navigator.of(context).push(FadePageRoute(
opaque: false, // transparent background builder: (context) =>
pageBuilder: (context, animation, __) { ZoomableImageRoute(imageUrl: url, heroKey: _key),
photoViewOpened = true; barrierDismissible: 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))),
);
}));
}, },
); );
} }
} }
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:async';
import 'dart:math';
import 'package:draggable_scrollbar/draggable_scrollbar.dart'; import 'package:draggable_scrollbar/draggable_scrollbar.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:fluttericon/font_awesome5_icons.dart'; import 'package:fluttericon/font_awesome5_icons.dart';
import 'package:freezer/api/cache.dart'; import 'package:freezer/api/cache.dart';
import 'package:freezer/api/deezer.dart'; import 'package:freezer/api/deezer.dart';
@ -77,11 +79,14 @@ class _AlbumDetailsState extends State<AlbumDetails> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
const SizedBox(height: 8.0), const SizedBox(height: 8.0),
ZoomableImage( ConstrainedBox(
constraints: BoxConstraints.loose(
MediaQuery.of(context).size / 3),
child: ZoomableImage(
url: album!.art!.full, url: album!.art!.full,
width: MediaQuery.of(context).size.width / 2,
rounded: true, rounded: true,
), ),
),
const SizedBox(height: 8.0), const SizedBox(height: 8.0),
Text( Text(
album!.title!, album!.title!,
@ -228,12 +233,14 @@ class _AlbumDetailsState extends State<AlbumDetails> {
), ),
...List.generate( ...List.generate(
tracks.length, tracks.length,
(i) => TrackTile(tracks[i]!, onTap: () { (i) =>
TrackTile.fromTrack(tracks[i]!, onTap: () {
playerHelper.playFromAlbum( playerHelper.playFromAlbum(
album!, tracks[i]!.id); album!, tracks[i]!.id);
}, onHold: () { }, onSecondary: (details) {
MenuSheet m = MenuSheet(context); 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 { class ArtistDetails extends StatefulWidget {
late final Artist artist; final Artist artist;
late final Future? _future;
ArtistDetails(Artist artist, {Key? key}) : super(key: key) { const ArtistDetails(this.artist, {super.key});
FutureOr<Artist> future = _loadArtist(artist);
@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) { if (future is Artist) {
this.artist = future; _future = Future.value(widget.artist);
_future = null;
} else { } else {
_future = future.then((value) => this.artist = value); _future = future;
} }
super.initState();
} }
FutureOr<Artist> _loadArtist(Artist artist) { FutureOr<Artist> _loadArtist(Artist artist) {
@ -318,44 +332,53 @@ class ArtistDetails extends StatelessWidget {
if ((artist.albums ?? []).isEmpty) { if ((artist.albums ?? []).isEmpty) {
return deezerAPI.artist(artist.id); return deezerAPI.artist(artist.id);
} }
return artist; return artist;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: FutureBuilder( body: FutureBuilder<Artist>(
future: _future ?? Future.value(), future: _future,
builder: (BuildContext context, AsyncSnapshot snapshot) { builder: (BuildContext context, snapshot) {
//Error / not done //Error / not done
if (snapshot.hasError) return const ErrorScreen(); if (snapshot.hasError) return const ErrorScreen();
if (snapshot.connectionState != ConnectionState.done) { if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
final artist = snapshot.data!;
return ListView( return ListView(
children: <Widget>[ children: <Widget>[
const SizedBox(height: 4.0), const SizedBox(height: 4.0),
Row( Padding(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, padding: const EdgeInsets.all(16.0),
child: SizedBox(
height: MediaQuery.of(context).size.height / 3,
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[ children: <Widget>[
ZoomableImage( Flexible(
url: artist.picture!.full, child: ZoomableImage(
width: MediaQuery.of(context).size.width / 2 - 8, url: widget.artist.picture!.full,
rounded: true, rounded: true,
), ),
),
SizedBox( SizedBox(
width: MediaQuery.of(context).size.width / 2 - 24, width: min(
MediaQuery.of(context).size.width / 16, 60.0)),
Expanded(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(
artist.name!, artist.name!,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
maxLines: 4, maxLines: 4,
textAlign: TextAlign.center,
style: const TextStyle( style: const TextStyle(
fontSize: 24.0, fontWeight: FontWeight.bold), fontSize: 24.0, fontWeight: FontWeight.bold),
), ),
@ -386,7 +409,7 @@ class ArtistDetails extends StatelessWidget {
), ),
const SizedBox(width: 8.0), const SizedBox(width: 8.0),
Text( Text(
artist.albumCount.toString(), widget.artist.albumCount.toString(),
style: const TextStyle(fontSize: 16), style: const TextStyle(fontSize: 16),
) )
], ],
@ -396,7 +419,8 @@ class ArtistDetails extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 4.0), ),
),
const FreezerDivider(), const FreezerDivider(),
Row( Row(
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
@ -411,7 +435,7 @@ class ArtistDetails extends StatelessWidget {
], ],
), ),
onPressed: () async { onPressed: () async {
await deezerAPI.addFavoriteArtist(artist.id); await deezerAPI.addFavoriteArtist(widget.artist.id);
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context)
.snack('Added to library'.i18n); .snack('Added to library'.i18n);
}, },
@ -485,15 +509,15 @@ class ArtistDetails extends StatelessWidget {
return const SizedBox(height: 0.0, width: 0.0); return const SizedBox(height: 0.0, width: 0.0);
} }
Track t = artist.topTracks![i]; Track t = artist.topTracks![i];
return TrackTile( return TrackTile.fromTrack(
t, t,
onTap: () { onTap: () {
playerHelper.playFromTopTracks( playerHelper.playFromTopTracks(
artist.topTracks!, t.id, artist); artist.topTracks!, t.id, artist);
}, },
onHold: () { onSecondary: (details) {
MenuSheet mi = MenuSheet(context); MenuSheet mi = MenuSheet(context);
mi.defaultTrackMenu(t); mi.defaultTrackMenu(t, details: details);
}, },
); );
}), }),
@ -542,9 +566,9 @@ class ArtistDetails extends StatelessWidget {
Navigator.of(context) Navigator.of(context)
.pushRoute(builder: (context) => AlbumDetails(a)); .pushRoute(builder: (context) => AlbumDetails(a));
}, },
onHold: () { onSecondary: (details) {
MenuSheet m = MenuSheet(context); MenuSheet m = MenuSheet(context);
m.defaultAlbumMenu(a); m.defaultAlbumMenu(a, details: details);
}, },
); );
}) })
@ -603,9 +627,9 @@ class _DiscographyScreenState extends State<DiscographyScreen> {
a, a,
onTap: () => Navigator.of(context) onTap: () => Navigator.of(context)
.push(MaterialPageRoute(builder: (context) => AlbumDetails(a))), .push(MaterialPageRoute(builder: (context) => AlbumDetails(a))),
onHold: () { onSecondary: (details) {
MenuSheet m = MenuSheet(context); MenuSheet m = MenuSheet(context);
m.defaultAlbumMenu(a); m.defaultAlbumMenu(a, details: details);
}, },
); );
@ -857,57 +881,59 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
child: ListView( child: ListView(
controller: _scrollController, controller: _scrollController,
children: <Widget>[ children: <Widget>[
Container( const SizedBox(height: 4.0),
height: 4.0, ConstrainedBox(
), constraints: BoxConstraints.tight(
Padding( Size.fromHeight(MediaQuery.of(context).size.height / 3)),
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0), child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
children: <Widget>[ children: <Widget>[
CachedImage( Flexible(
child: CachedImage(
url: playlist!.image!.full, url: playlist!.image!.full,
height: MediaQuery.of(context).size.width / 2 - 8,
rounded: true, rounded: true,
fullThumb: true, fullThumb: true,
), ),
),
SizedBox( SizedBox(
width: MediaQuery.of(context).size.width / 2 - 8, width: min(MediaQuery.of(context).size.width / 16, 60.0)),
Expanded(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(
playlist!.title!, playlist!.title!,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center, textAlign: TextAlign.start,
maxLines: 3, maxLines: 3,
style: const TextStyle( style: const TextStyle(
fontSize: 20.0, fontWeight: FontWeight.bold), fontSize: 20.0, fontWeight: FontWeight.bold),
), ),
Container(height: 4.0),
Text( Text(
playlist!.user!.name ?? '', playlist!.user!.name ?? '',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
maxLines: 2, maxLines: 2,
textAlign: TextAlign.center, textAlign: TextAlign.start,
style: TextStyle( style: TextStyle(
color: Theme.of(context).primaryColor, color: Theme.of(context).primaryColor,
fontSize: 17.0), fontSize: 17.0),
), ),
Container(height: 10.0), const SizedBox(height: 16.0),
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
Icon( Icon(
Icons.audiotrack, Icons.audiotrack,
size: 32.0, size: 20.0,
semanticLabel: "Tracks".i18n, semanticLabel: "Tracks".i18n,
), ),
Container( const SizedBox(width: 8.0),
width: 8.0,
),
Text( Text(
(playlist!.trackCount ?? playlist!.tracks!.length) (playlist!.trackCount ?? playlist!.tracks!.length)
.toString(), .toString(),
@ -915,6 +941,7 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
) )
], ],
), ),
const SizedBox(height: 6.0),
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
@ -923,9 +950,7 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
size: 32.0, size: 32.0,
semanticLabel: "Duration".i18n, semanticLabel: "Duration".i18n,
), ),
Container( const SizedBox(width: 8.0),
width: 8.0,
),
Text( Text(
playlist!.durationString, playlist!.durationString,
style: const TextStyle(fontSize: 16), style: const TextStyle(fontSize: 16),
@ -934,10 +959,11 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
), ),
], ],
), ),
) ),
], ],
), ),
), ),
),
if (playlist!.description != null && if (playlist!.description != null &&
playlist!.description!.isNotEmpty) playlist!.description!.isNotEmpty)
const FreezerDivider(), const FreezerDivider(),
@ -1057,13 +1083,13 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
const FreezerDivider(), const FreezerDivider(),
...List.generate(playlist!.tracks!.length, (i) { ...List.generate(playlist!.tracks!.length, (i) {
Track t = sorted[i]; Track t = sorted[i];
return TrackTile(t, onTap: () { return TrackTile.fromTrack(t, onTap: () {
Playlist p = Playlist( Playlist p = Playlist(
title: playlist!.title, id: playlist!.id, tracks: sorted); title: playlist!.title, id: playlist!.id, tracks: sorted);
playerHelper.playFromPlaylist(p, t.id); playerHelper.playFromPlaylist(p, t.id);
}, onHold: () { }, onSecondary: (details) {
MenuSheet m = MenuSheet(context); MenuSheet m = MenuSheet(context);
m.defaultTrackMenu(t, options: [ m.defaultTrackMenu(t, details: details, options: [
(playlist!.user!.id == deezerAPI.userId) (playlist!.user!.id == deezerAPI.userId)
? m.removeFromPlaylist(t, playlist) ? m.removeFromPlaylist(t, playlist)
: const SizedBox( : const SizedBox(
@ -1138,9 +1164,7 @@ class _MakePlaylistOfflineState extends State<MakePlaylistOffline> {
}); });
}, },
), ),
Container( const SizedBox(width: 4.0),
width: 4.0,
),
Text( Text(
'Offline'.i18n, 'Offline'.i18n,
style: const TextStyle(fontSize: 16), style: const TextStyle(fontSize: 16),

View file

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

View file

@ -168,7 +168,8 @@ class LibraryScreen extends StatelessWidget {
if (DownloadManager.isSupported) if (DownloadManager.isSupported)
ExpansionTile( ExpansionTile(
title: Text('Statistics'.i18n), title: Text('Statistics'.i18n),
leading: const LeadingIcon(Icons.insert_chart, color: Colors.grey), leading:
const LeadingIcon(Icons.insert_chart, color: Colors.grey),
children: <Widget>[ children: <Widget>[
FutureBuilder( FutureBuilder(
future: downloadManager.getStats(), future: downloadManager.getStats(),
@ -501,7 +502,7 @@ class _LibraryTracksState extends State<LibraryTracks> {
Track? t = (tracks.length == (trackCount ?? 0)) Track? t = (tracks.length == (trackCount ?? 0))
? _sorted[i] ? _sorted[i]
: tracks[i]; : tracks[i];
return TrackTile( return TrackTile.fromTrack(
t, t,
onTap: () { onTap: () {
playerHelper.playFromTrackList( playerHelper.playFromTrackList(
@ -514,9 +515,9 @@ class _LibraryTracksState extends State<LibraryTracks> {
text: 'Favorites'.i18n, text: 'Favorites'.i18n,
source: 'playlist')); source: 'playlist'));
}, },
onHold: () { onSecondary: (details) {
MenuSheet m = MenuSheet(context); MenuSheet m = MenuSheet(context);
m.defaultTrackMenu(t, onRemove: () { m.defaultTrackMenu(t, details: details, onRemove: () {
setState(() { setState(() {
tracks.removeWhere((track) => t.id == track.id); tracks.removeWhere((track) => t.id == track.id);
}); });
@ -533,7 +534,7 @@ class _LibraryTracksState extends State<LibraryTracks> {
), ),
const SizedBox(height: 8.0), const SizedBox(height: 8.0),
for (final track in allTracks) for (final track in allTracks)
TrackTile(track!, onTap: () { TrackTile.fromTrack(track!, onTap: () {
playerHelper.playFromTrackList( playerHelper.playFromTrackList(
allTracks, allTracks,
track.id, track.id,
@ -541,8 +542,9 @@ class _LibraryTracksState extends State<LibraryTracks> {
id: 'allTracks', id: 'allTracks',
text: 'All offline tracks'.i18n, text: 'All offline tracks'.i18n,
source: 'offline')); source: 'offline'));
}, onHold: () { }, onSecondary: (details) {
MenuSheet(context).defaultTrackMenu(track); MenuSheet(context)
.defaultTrackMenu(track, details: details);
}), }),
], ],
))); )));
@ -689,11 +691,15 @@ class _LibraryAlbumsState extends State<LibraryAlbums> {
Navigator.of(context).pushRoute( Navigator.of(context).pushRoute(
builder: (context) => AlbumDetails(a)); builder: (context) => AlbumDetails(a));
}, },
onHold: () async { onSecondary: (details) {
MenuSheet m = MenuSheet(context); MenuSheet m = MenuSheet(context);
m.defaultAlbumMenu(a, onRemove: () { m.defaultAlbumMenu(
a,
details: details,
onRemove: () {
setState(() => _albums!.remove(a)); setState(() => _albums!.remove(a));
}); },
);
}, },
); );
}), }),
@ -727,9 +733,10 @@ class _LibraryAlbumsState extends State<LibraryAlbums> {
Navigator.of(context).pushRoute( Navigator.of(context).pushRoute(
builder: (context) => AlbumDetails(a)); builder: (context) => AlbumDetails(a));
}, },
onHold: () async { onSecondary: (details) {
MenuSheet m = MenuSheet(context); MenuSheet m = MenuSheet(context);
m.defaultAlbumMenu(a, onRemove: () { m.defaultAlbumMenu(a, details: details,
onRemove: () {
setState(() { setState(() {
albums.remove(a); albums.remove(a);
_albums!.remove(a); _albums!.remove(a);
@ -1090,10 +1097,10 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
Navigator.of(context).pushRoute( Navigator.of(context).pushRoute(
builder: (context) => PlaylistDetails(favoritesPlaylist)); builder: (context) => PlaylistDetails(favoritesPlaylist));
}, },
onHold: () { onSecondary: (details) {
MenuSheet m = MenuSheet(context); MenuSheet m = MenuSheet(context);
favoritesPlaylist.library = true; favoritesPlaylist.library = true;
m.defaultPlaylistMenu(favoritesPlaylist); m.defaultPlaylistMenu(favoritesPlaylist, details: details);
}, },
), ),
@ -1104,9 +1111,9 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
p, p,
onTap: () => Navigator.of(context) onTap: () => Navigator.of(context)
.pushRoute(builder: (context) => PlaylistDetails(p)), .pushRoute(builder: (context) => PlaylistDetails(p)),
onHold: () { onSecondary: (details) {
MenuSheet m = MenuSheet(context); MenuSheet m = MenuSheet(context);
m.defaultPlaylistMenu(p, onRemove: () { m.defaultPlaylistMenu(p, details: details, onRemove: () {
setState(() => _playlists!.remove(p)); setState(() => _playlists!.remove(p));
}, onUpdate: () { }, onUpdate: () {
_load(); _load();
@ -1140,9 +1147,10 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
p, p,
onTap: () => Navigator.of(context).pushRoute( onTap: () => Navigator.of(context).pushRoute(
builder: (context) => PlaylistDetails(p)), builder: (context) => PlaylistDetails(p)),
onHold: () { onSecondary: (details) {
MenuSheet m = MenuSheet(context); MenuSheet m = MenuSheet(context);
m.defaultPlaylistMenu(p, onRemove: () { m.defaultPlaylistMenu(p, details: details,
onRemove: () {
setState(() { setState(() {
playlists.remove(p); playlists.remove(p);
_playlists!.remove(p); _playlists!.remove(p);
@ -1196,7 +1204,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
itemCount: cache.history.length, itemCount: cache.history.length,
itemBuilder: (BuildContext context, int i) { itemBuilder: (BuildContext context, int i) {
Track t = cache.history[cache.history.length - i - 1]; Track t = cache.history[cache.history.length - i - 1];
return TrackTile( return TrackTile.fromTrack(
t, t,
onTap: () { onTap: () {
playerHelper.playFromTrackList( playerHelper.playFromTrackList(
@ -1205,9 +1213,9 @@ class _HistoryScreenState extends State<HistoryScreen> {
QueueSource( QueueSource(
id: null, text: 'History'.i18n, source: 'history')); id: null, text: 'History'.i18n, source: 'history'));
}, },
onHold: () { onSecondary: (details) {
MenuSheet m = MenuSheet(context); 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_bar.dart';
import 'package:freezer/ui/player_screen.dart'; import 'package:freezer/ui/player_screen.dart';
class LyricsScreen extends StatefulWidget { class LyricsScreen extends StatelessWidget {
const LyricsScreen({Key? key}) : super(key: key); const LyricsScreen({super.key});
@override @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 _mediaItemSub;
late StreamSubscription _playbackStateSub; late StreamSubscription _playbackStateSub;
int? _currentIndex = -1; int? _currentIndex = -1;
int? _prevIndex = -1; int? _prevIndex = -1;
final ScrollController _controller = ScrollController(); final ScrollController _controller = ScrollController();
final double height = 90; final double height = 90;
BoxConstraints? _widgetConstraints;
Lyrics? _lyrics; Lyrics? _lyrics;
bool _loading = true; bool _loading = true;
CancelableOperation<Lyrics>? _lyricsCancelable; CancelableOperation<Lyrics>? _lyricsCancelable;
@ -60,7 +85,9 @@ class _LyricsScreenState extends State<LyricsScreen> {
_loading = false; _loading = false;
_lyrics = lyrics; _lyrics = lyrics;
}); });
_scrollToLyric();
SchedulerBinding.instance.addPostFrameCallback(
(_) => _updatePosition(audioHandler.playbackState.value.position));
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@ -72,8 +99,15 @@ class _LyricsScreenState extends State<LyricsScreen> {
Future<void> _scrollToLyric() async { Future<void> _scrollToLyric() async {
if (!_controller.hasClients) return; if (!_controller.hasClients) return;
//Lyric height, screen height, appbar height //Lyric height, screen height, appbar height
double scrollTo = (height * _currentIndex!) - double scrollTo;
if (_widgetConstraints == null) {
scrollTo = (height * _currentIndex!) -
(MediaQuery.of(context).size.height / 4 + height / 2); (MediaQuery.of(context).size.height / 4 + height / 2);
} else {
final widgetHeight = _widgetConstraints!.maxHeight;
final minScroll = height * _currentIndex!;
scrollTo = minScroll - widgetHeight / 2 + height / 2;
}
print( print(
'${height * _currentIndex!}, ${MediaQuery.of(context).size.height / 2}'); '${height * _currentIndex!}, ${MediaQuery.of(context).size.height / 2}');
@ -87,12 +121,7 @@ class _LyricsScreenState extends State<LyricsScreen> {
_animatedScroll = false; _animatedScroll = false;
} }
@override void _updatePosition(Duration position) {
void initState() {
SchedulerBinding.instance.addPostFrameCallback((_) {
//Enable visualizer
// if (settings.lyricsVisualizer) playerHelper.startVisualizer();
_playbackStateSub = AudioService.position.listen((position) {
if (_loading) return; if (_loading) return;
if (!_syncedLyrics) return; if (!_syncedLyrics) return;
_currentIndex = _currentIndex =
@ -105,7 +134,14 @@ class _LyricsScreenState extends State<LyricsScreen> {
_prevIndex = _currentIndex; _prevIndex = _currentIndex;
if (_freeScroll) return; if (_freeScroll) return;
_scrollToLyric(); _scrollToLyric();
}); }
@override
void initState() {
SchedulerBinding.instance.addPostFrameCallback((_) {
//Enable visualizer
// if (settings.lyricsVisualizer) playerHelper.startVisualizer();
_playbackStateSub = AudioService.position.listen(_updatePosition);
}); });
if (audioHandler.mediaItem.value != null) { if (audioHandler.mediaItem.value != null) {
_loadForId(audioHandler.mediaItem.value!.id); _loadForId(audioHandler.mediaItem.value!.id);
@ -130,19 +166,17 @@ class _LyricsScreenState extends State<LyricsScreen> {
super.dispose(); super.dispose();
} }
ScrollBehavior get _scrollBehavior {
if (_freeScroll) {
return ScrollConfiguration.of(context);
}
return ScrollConfiguration.of(context).copyWith(scrollbars: false);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PlayerScreenBackground( return Column(children: [
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) if (_freeScroll && !_loading)
Center( Center(
child: TextButton( child: TextButton(
@ -160,9 +194,7 @@ class _LyricsScreenState extends State<LyricsScreen> {
)), )),
), ),
Expanded( Expanded(
child: Stack(children: [ child: _error != null
//Lyrics
_error != null
? ?
//Shouldn't really happen, empty lyrics have own text //Shouldn't really happen, empty lyrics have own text
ErrorScreen(message: _error.toString()) ErrorScreen(message: _error.toString())
@ -170,9 +202,10 @@ class _LyricsScreenState extends State<LyricsScreen> {
// Loading lyrics // Loading lyrics
_loading _loading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: NotificationListener<ScrollStartNotification>( : LayoutBuilder(builder: (context, constraints) {
onNotification: _widgetConstraints = constraints;
(ScrollStartNotification notification) { return NotificationListener<ScrollStartNotification>(
onNotification: (ScrollStartNotification notification) {
if (!_syncedLyrics) return false; if (!_syncedLyrics) return false;
final extentDiff = final extentDiff =
(notification.metrics.extentBefore - (notification.metrics.extentBefore -
@ -188,6 +221,8 @@ class _LyricsScreenState extends State<LyricsScreen> {
} }
return false; return false;
}, },
child: ScrollConfiguration(
behavior: _scrollBehavior,
child: ListView.builder( child: ListView.builder(
controller: _controller, controller: _controller,
itemCount: _lyrics!.lyrics!.length, itemCount: _lyrics!.lyrics!.length,
@ -216,7 +251,8 @@ class _LyricsScreenState extends State<LyricsScreen> {
child: Padding( child: Padding(
padding: _currentIndex == i padding: _currentIndex == i
? EdgeInsets.zero ? EdgeInsets.zero
: const EdgeInsets.symmetric( : const EdgeInsets
.symmetric(
horizontal: 1.0), horizontal: 1.0),
child: Text( child: Text(
_lyrics!.lyrics![i].text!, _lyrics!.lyrics![i].text!,
@ -236,39 +272,9 @@ class _LyricsScreenState extends State<LyricsScreen> {
), ),
)))); ))));
}, },
)), )));
}),
//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:async';
import 'dart:ffi';
import 'package:freezer/main.dart'; import 'package:freezer/main.dart';
import 'package:freezer/ui/player_bar.dart'; import 'package:freezer/ui/player_bar.dart';
@ -132,8 +133,9 @@ class MenuSheet {
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
enableDrag: true, enableDrag: false,
showDragHandle: false, showDragHandle: false,
elevation: 0.0,
builder: (BuildContext context) { builder: (BuildContext context) {
return DraggableScrollableSheet( return DraggableScrollableSheet(
initialChildSize: 0.5, initialChildSize: 0.5,
@ -160,8 +162,12 @@ class MenuSheet {
} }
//Default track options //Default track options
void defaultTrackMenu(Track track, void defaultTrackMenu(
{List<Widget> options = const [], Function? onRemove}) { Track track, {
List<Widget> options = const [],
Function? onRemove,
TapDownDetails? details,
}) {
showWithTrack(track, [ showWithTrack(track, [
addToQueueNext(track), addToQueueNext(track),
addToQueue(track), addToQueue(track),
@ -359,7 +365,9 @@ class MenuSheet {
//Default album options //Default album options
void defaultAlbumMenu(Album album, void defaultAlbumMenu(Album album,
{List<Widget> options = const [], Function? onRemove}) { {List<Widget> options = const [],
Function? onRemove,
TapDownDetails? details}) {
show([ show([
album.library! album.library!
? removeAlbum(album, onRemove: onRemove) ? removeAlbum(album, onRemove: onRemove)
@ -424,7 +432,9 @@ class MenuSheet {
//=================== //===================
void defaultArtistMenu(Artist artist, void defaultArtistMenu(Artist artist,
{List<Widget> options = const [], Function? onRemove}) { {List<Widget> options = const [],
Function? onRemove,
TapDownDetails? details}) {
show([ show([
artist.library! artist.library!
? removeArtist(artist, onRemove: onRemove) ? removeArtist(artist, onRemove: onRemove)
@ -467,8 +477,10 @@ class MenuSheet {
void defaultPlaylistMenu(Playlist playlist, void defaultPlaylistMenu(Playlist playlist,
{List<Widget> options = const [], {List<Widget> options = const [],
Function? onRemove, Function? onRemove,
Function? onUpdate}) { Function? onUpdate,
TapDownDetails? details}) {
show([ show([
if (playlist.library != null)
playlist.library! playlist.library!
? removePlaylistLibrary(playlist, onRemove: onRemove) ? removePlaylistLibrary(playlist, onRemove: onRemove)
: addPlaylistLibrary(playlist), : addPlaylistLibrary(playlist),

View file

@ -198,7 +198,7 @@ class FancyScaffoldState extends State<FancyScaffold>
} }
} }
class PlayerBar extends StatefulWidget { class PlayerBar extends StatelessWidget {
final VoidCallback? onTap; final VoidCallback? onTap;
final bool shouldHaveHero; final bool shouldHaveHero;
final Color? backgroundColor; final Color? backgroundColor;
@ -211,14 +211,7 @@ class PlayerBar extends StatefulWidget {
this.focusNode, this.focusNode,
}) : super(key: key); }) : super(key: key);
@override
State<PlayerBar> createState() => _PlayerBarState();
}
class _PlayerBarState extends State<PlayerBar> {
final double iconSize = 28; final double iconSize = 28;
late StreamSubscription mediaItemSub;
late bool _isNothingPlaying = audioHandler.mediaItem.value == null;
double parsePosition(Duration position) { double parsePosition(Duration position) {
if (audioHandler.mediaItem.value == null) return 0.0; if (audioHandler.mediaItem.value == null) return 0.0;
@ -229,59 +222,26 @@ class _PlayerBarState extends State<PlayerBar> {
audioHandler.mediaItem.value!.duration!.inSeconds; audioHandler.mediaItem.value!.duration!.inSeconds;
} }
@override Color? get _backgroundColor => backgroundColor;
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;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( return SizedBox(
height: 68.0, 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();
}
//Left
if (details.delta.dx < -sensitivity) {
await audioHandler.skipToNext();
}
_gestureRegistered = false;
return;
},
child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[ child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
Expanded( Expanded(
child: StreamBuilder<MediaItem?>( child: StreamBuilder<MediaItem?>(
stream: audioHandler.mediaItem, stream: audioHandler.mediaItem,
initialData: audioHandler.mediaItem.valueOrNull, initialData: audioHandler.mediaItem.valueOrNull,
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox(); if (snapshot.data == null) {
return Material(
child: ListTile(
leading: Image.asset('assets/cover_thumb.jpg'),
title: Text('Nothing is currently playing'.i18n),
),
);
}
final currentMediaItem = snapshot.data!; final currentMediaItem = snapshot.data!;
final image = CachedImage( final image = CachedImage(
width: 50, width: 50,
@ -289,16 +249,16 @@ class _PlayerBarState extends State<PlayerBar> {
url: currentMediaItem.extras!['thumb'] ?? url: currentMediaItem.extras!['thumb'] ??
currentMediaItem.artUri.toString(), currentMediaItem.artUri.toString(),
); );
final leadingWidget = widget.shouldHaveHero final leadingWidget = shouldHaveHero
? Hero(tag: currentMediaItem.id, child: image) ? Hero(tag: currentMediaItem.id, child: image)
: image; : image;
return Material( return Material(
child: ListTile( child: ListTile(
dense: true, tileColor: _backgroundColor,
focusNode: widget.focusNode, focusNode: focusNode,
contentPadding: contentPadding:
const EdgeInsets.symmetric(horizontal: 8.0), const EdgeInsets.symmetric(horizontal: 8.0),
onTap: widget.onTap, onTap: onTap,
leading: AnimatedSwitcher( leading: AnimatedSwitcher(
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),
child: leadingWidget), child: leadingWidget),
@ -342,7 +302,6 @@ class _PlayerBarState extends State<PlayerBar> {
}), }),
), ),
]), ]),
),
); );
} }
} }

View file

@ -1,6 +1,7 @@
import 'dart:ui'; import 'dart:ui';
import 'dart:async'; import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'package:flutter/services.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/deezer.dart';
import 'package:freezer/api/definitions.dart'; import 'package:freezer/api/definitions.dart';
import 'package:freezer/api/player.dart'; import 'package:freezer/api/player.dart';
import 'package:freezer/main.dart';
import 'package:freezer/page_routes/fade.dart'; import 'package:freezer/page_routes/fade.dart';
import 'package:freezer/settings.dart'; import 'package:freezer/settings.dart';
import 'package:freezer/translations.i18n.dart'; import 'package:freezer/translations.i18n.dart';
@ -91,7 +93,9 @@ class PlayerScreen extends StatelessWidget {
return ChangeNotifierProvider( return ChangeNotifierProvider(
create: (context) => BackgroundProvider(), create: (context) => BackgroundProvider(),
child: PlayerScreenBackground( child: PlayerScreenBackground(
child: OrientationBuilder( child: MainScreen.of(context).isDesktop
? const PlayerScreenDesktop()
: OrientationBuilder(
builder: (context, orientation) => builder: (context, orientation) =>
orientation == Orientation.landscape orientation == Orientation.landscape
? const PlayerScreenHorizontal() ? const PlayerScreenHorizontal()
@ -287,8 +291,11 @@ class PlayerScreenVertical extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 24.0), padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: PlayerTextSubtext(textSize: 64.sp), child: PlayerTextSubtext(textSize: 64.sp),
), ),
SeekBar(textSize: 48.sp), SeekBar(textSize: 38.sp),
PlaybackControls(86.sp), Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: PlaybackControls(86.sp),
),
Padding( Padding(
padding: padding:
const EdgeInsets.symmetric(vertical: 0, horizontal: 16.0), 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 { class FitOrScrollText extends StatefulWidget {
final String text; final String text;
final TextStyle style; final TextStyle style;
@ -741,23 +840,32 @@ class _BigAlbumArtState extends State<BigAlbumArt> {
// }, // },
onTap: () => Navigator.push( onTap: () => Navigator.push(
context, context,
PageRouteBuilder( FadePageRoute(
opaque: false, // transparent background
barrierDismissible: true, barrierDismissible: true,
pageBuilder: (context, animation, __) { builder: (context) {
return FadeTransition( final mediaItem = audioHandler.mediaItem.value!;
opacity: animation, return ZoomableImageRoute(
child: PhotoView( imageUrl: mediaItem.artUri.toString(), heroKey: mediaItem.id);
imageProvider: CachedNetworkImageProvider( },
audioHandler.mediaItem.value!.artUri.toString()), )
maxScale: 8.0, // PageRouteBuilder(
minScale: 0.2, // opaque: false, // transparent background
heroAttributes: PhotoViewHeroAttributes( // barrierDismissible: true,
tag: audioHandler.mediaItem.value!.id), // pageBuilder: (context, animation, __) {
backgroundDecoration: const BoxDecoration( // return FadeTransition(
color: Color.fromARGB(0x90, 0, 0, 0))), // 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, onHorizontalDragDown: (_) => _userScroll = true,
// delayed a bit, so to make sure that the page view updated. // delayed a bit, so to make sure that the page view updated.
onHorizontalDragEnd: (_) => Future.delayed( onHorizontalDragEnd: (_) => Future.delayed(
@ -826,12 +934,14 @@ class PlayerScreenTopRow extends StatelessWidget {
final double? iconSize; final double? iconSize;
final double? textWidth; final double? textWidth;
final bool short; final bool short;
final bool showQueueButton; // not needed on desktop
const PlayerScreenTopRow( const PlayerScreenTopRow(
{super.key, {super.key,
this.textSize, this.textSize,
this.iconSize, this.iconSize,
this.textWidth, this.textWidth,
this.short = false}); this.short = false,
this.showQueueButton = true});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -867,7 +977,8 @@ class PlayerScreenTopRow extends StatelessWidget {
TextSpan(text: playerHelper.queueSource!.text ?? '') TextSpan(text: playerHelper.queueSource!.text ?? '')
], style: TextStyle(fontSize: textSize ?? 38.sp))), ], style: TextStyle(fontSize: textSize ?? 38.sp))),
), ),
IconButton( showQueueButton
? IconButton(
icon: Icon( icon: Icon(
Icons.menu, Icons.menu,
semanticLabel: "Queue".i18n, semanticLabel: "Queue".i18n,
@ -876,7 +987,8 @@ class PlayerScreenTopRow extends StatelessWidget {
splashRadius: size * 1.5, splashRadius: size * 1.5,
onPressed: () => Navigator.of(context) onPressed: () => Navigator.of(context)
.pushRoute(builder: (context) => const QueueScreen()), .pushRoute(builder: (context) => const QueueScreen()),
), )
: SizedBox.square(dimension: size + 16.0),
], ],
); );
} }
@ -997,7 +1109,13 @@ class _SeekBarState extends State<SeekBar> {
class BottomBarControls extends StatelessWidget { class BottomBarControls extends StatelessWidget {
final double size; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -1018,6 +1136,8 @@ class BottomBarControls extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[ children: <Widget>[
QualityInfoWidget(textSize: size * 0.75),
if (showLyricsButton)
IconButton( IconButton(
iconSize: size, iconSize: size,
icon: Icon( icon: Icon(
@ -1055,7 +1175,6 @@ class BottomBarControls extends StatelessWidget {
// toastLength: Toast.LENGTH_SHORT); // toastLength: Toast.LENGTH_SHORT);
// }, // },
// ), // ),
QualityInfoWidget(textSize: size * 0.75),
FavoriteButton(size: size * 0.85), FavoriteButton(size: size * 0.85),
PlayerMenuButton(size: size) PlayerMenuButton(size: size)
], ],

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:math';
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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/menu.dart';
import 'package:freezer/ui/tiles.dart'; import 'package:freezer/ui/tiles.dart';
class QueueScreen extends StatefulWidget { class QueueScreen extends StatelessWidget {
const QueueScreen({super.key}); const QueueScreen({super.key});
@override @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> { class QueueListWidget extends StatefulWidget {
static const itemExtent = 72.0; // height of each TrackTile 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(); final _scrollController = ScrollController();
late StreamSubscription _queueSub; late StreamSubscription _queueSub;
static const _dismissibleBackground = DecoratedBox( static const _dismissibleBackground = DecoratedBox(
@ -55,7 +91,9 @@ class _QueueScreenState extends State<QueueScreen> {
}); });
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
// calculate position of current item // calculate position of current item
final position = playerHelper.queueIndex * itemExtent; double position = min(playerHelper.queueIndex * itemExtent,
_scrollController.position.maxScrollExtent);
_scrollController.jumpTo(position); _scrollController.jumpTo(position);
}); });
super.initState(); super.initState();
@ -69,33 +107,7 @@ class _QueueScreenState extends State<QueueScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return ReorderableListView.builder(
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, buildDefaultDragHandles: false,
scrollController: _scrollController, scrollController: _scrollController,
// specify the itemExtent normally and remove it when reordering because of an issue with [SliverFixedExtentList] // specify the itemExtent normally and remove it when reordering because of an issue with [SliverFixedExtentList]
@ -112,10 +124,10 @@ class _QueueScreenState extends State<QueueScreen> {
}, },
itemCount: _queueCache.length, itemCount: _queueCache.length,
itemBuilder: (BuildContext context, int index) { itemBuilder: (BuildContext context, int index) {
Track track = Track.fromMediaItem(_queueCache[index]); final mediaItem = _queueCache[index];
final int itemId = _queueCache[index].extras!['id'] ?? 0; final int itemId = mediaItem.extras!['id'] ?? 0;
return Dismissible( return Dismissible(
key: ValueKey(track.id.hashCode ^ itemId), key: ValueKey(mediaItem.id.hashCode | itemId),
background: _dismissibleBackground, background: _dismissibleBackground,
secondaryBackground: _dismissibleSecondaryBackground, secondaryBackground: _dismissibleSecondaryBackground,
onDismissed: (_) { onDismissed: (_) {
@ -145,22 +157,24 @@ class _QueueScreenState extends State<QueueScreen> {
}, },
child: SizedBox( child: SizedBox(
height: itemExtent, height: itemExtent,
child: TrackTile( child: TrackTile.fromMediaItem(
track, mediaItem,
trailing: ReorderableDragStartListener( trailing: ReorderableDragStartListener(
index: index, child: const Icon(Icons.drag_handle)), index: index, child: const Icon(Icons.drag_handle)),
onTap: () { onTap: () {
audioHandler.skipToQueueItem(index).then((value) { audioHandler.skipToQueueItem(index).then((value) {
if (widget.shouldPopOnTap) {
Navigator.of(context).pop(); 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; final data = cache.searchHistory[i].data;
switch (cache.searchHistory[i].type) { switch (cache.searchHistory[i].type) {
case SearchHistoryItemType.track: case SearchHistoryItemType.track:
return TrackTile( return TrackTile.fromTrack(
data, data,
onTap: () { onTap: () {
List<Track?> queue = cache.searchHistory List<Track?> queue = cache.searchHistory
@ -293,8 +293,8 @@ class _SearchScreenState extends State<SearchScreen> {
source: 'searchhistory', source: 'searchhistory',
id: 'searchhistory')); id: 'searchhistory'));
}, },
onHold: () => onSecondary: (details) => MenuSheet(context)
MenuSheet(context).defaultTrackMenu(data), .defaultTrackMenu(data, details: details),
trailing: _removeHistoryItemWidget(i), trailing: _removeHistoryItemWidget(i),
); );
case SearchHistoryItemType.album: case SearchHistoryItemType.album:
@ -304,8 +304,8 @@ class _SearchScreenState extends State<SearchScreen> {
Navigator.of(context).pushRoute( Navigator.of(context).pushRoute(
builder: (context) => AlbumDetails(data)); builder: (context) => AlbumDetails(data));
}, },
onHold: () => onSecondary: (details) => MenuSheet(context)
MenuSheet(context).defaultAlbumMenu(data), .defaultAlbumMenu(data, details: details),
trailing: _removeHistoryItemWidget(i), trailing: _removeHistoryItemWidget(i),
); );
case SearchHistoryItemType.artist: case SearchHistoryItemType.artist:
@ -328,8 +328,9 @@ class _SearchScreenState extends State<SearchScreen> {
builder: (context) => builder: (context) =>
PlaylistDetails(data)); PlaylistDetails(data));
}, },
onHold: () => MenuSheet(context) onSecondary: (details) => MenuSheet(context)
.defaultPlaylistMenu(data), .defaultPlaylistMenu(data,
details: details),
trailing: _removeHistoryItemWidget(i), trailing: _removeHistoryItemWidget(i),
); );
default: default:
@ -477,7 +478,7 @@ class SearchResultsScreen extends StatelessWidget {
), ),
for (final track in results.tracks! for (final track in results.tracks!
.getRange(0, min(results.tracks!.length, 3))) .getRange(0, min(results.tracks!.length, 3)))
TrackTile(track, onTap: () { TrackTile.fromTrack(track, onTap: () {
cache.addToSearchHistory(track); cache.addToSearchHistory(track);
playerHelper.playFromTrackList( playerHelper.playFromTrackList(
results.tracks!, results.tracks!,
@ -486,9 +487,9 @@ class SearchResultsScreen extends StatelessWidget {
text: 'Search'.i18n, text: 'Search'.i18n,
id: query, id: query,
source: 'search')); source: 'search'));
}, onHold: () { }, onSecondary: (details) {
MenuSheet m = MenuSheet(context); MenuSheet m = MenuSheet(context);
m.defaultTrackMenu(track); m.defaultTrackMenu(track, details: details);
}), }),
ListTile( ListTile(
title: Text('Show all tracks'.i18n), title: Text('Show all tracks'.i18n),
@ -518,9 +519,9 @@ class SearchResultsScreen extends StatelessWidget {
), ),
for (final album in results.albums! for (final album in results.albums!
.getRange(0, min(results.albums!.length, 3))) .getRange(0, min(results.albums!.length, 3)))
AlbumTile(album, onHold: () { AlbumTile(album, onSecondary: (details) {
MenuSheet m = MenuSheet(context); MenuSheet m = MenuSheet(context);
m.defaultAlbumMenu(album); m.defaultAlbumMenu(album, details: details);
}, onTap: () { }, onTap: () {
cache.addToSearchHistory(album); cache.addToSearchHistory(album);
Navigator.of(context) Navigator.of(context)
@ -560,9 +561,9 @@ class SearchResultsScreen extends StatelessWidget {
Navigator.of(context).pushRoute( Navigator.of(context).pushRoute(
builder: (context) => ArtistDetails(artist)); builder: (context) => ArtistDetails(artist));
}, },
onHold: () { onSecondary: (details) {
MenuSheet m = MenuSheet(context); MenuSheet m = MenuSheet(context);
m.defaultArtistMenu(artist); m.defaultArtistMenu(artist, details: details);
}, },
), ),
])), ])),
@ -590,9 +591,9 @@ class SearchResultsScreen extends StatelessWidget {
Navigator.of(context).pushRoute( Navigator.of(context).pushRoute(
builder: (context) => PlaylistDetails(playlist)); builder: (context) => PlaylistDetails(playlist));
}, },
onHold: () { onSecondary: (details) {
MenuSheet m = MenuSheet(context); MenuSheet m = MenuSheet(context);
m.defaultPlaylistMenu(playlist); m.defaultPlaylistMenu(playlist, details: details);
}, },
), ),
ListTile( ListTile(
@ -702,14 +703,14 @@ class TrackListScreen extends StatelessWidget {
itemCount: tracks!.length, itemCount: tracks!.length,
itemBuilder: (BuildContext context, int i) { itemBuilder: (BuildContext context, int i) {
Track t = tracks![i]!; Track t = tracks![i]!;
return TrackTile( return TrackTile.fromTrack(
t, t,
onTap: () { onTap: () {
playerHelper.playFromTrackList(tracks!, t.id, queueSource); playerHelper.playFromTrackList(tracks!, t.id, queueSource);
}, },
onHold: () { onSecondary: (details) {
MenuSheet m = MenuSheet(context); MenuSheet m = MenuSheet(context);
m.defaultTrackMenu(t); m.defaultTrackMenu(t, details: details);
}, },
); );
}, },
@ -737,9 +738,9 @@ class AlbumListScreen extends StatelessWidget {
Navigator.of(context) Navigator.of(context)
.pushRoute(builder: (context) => AlbumDetails(a)); .pushRoute(builder: (context) => AlbumDetails(a));
}, },
onHold: () { onSecondary: (details) {
MenuSheet m = MenuSheet(context); MenuSheet m = MenuSheet(context);
m.defaultAlbumMenu(a!); m.defaultAlbumMenu(a!, details: details);
}, },
); );
}, },
@ -766,9 +767,9 @@ class SearchResultPlaylists extends StatelessWidget {
Navigator.of(context) Navigator.of(context)
.pushRoute(builder: (context) => PlaylistDetails(p)); .pushRoute(builder: (context) => PlaylistDetails(p));
}, },
onHold: () { onSecondary: (details) {
MenuSheet m = MenuSheet(context); 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.dart';
import 'package:country_pickers/country_picker_dialog.dart'; import 'package:country_pickers/country_picker_dialog.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart';
@ -144,6 +147,18 @@ class AppearanceSettings extends StatefulWidget {
class _AppearanceSettingsState extends State<AppearanceSettings> { class _AppearanceSettingsState extends State<AppearanceSettings> {
ColorSwatch<dynamic> _swatch(int c) => ColorSwatch(c, {500: Color(c)}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -411,7 +426,30 @@ class _AppearanceSettingsState extends State<AppearanceSettings> {
value: settings.useArtColor, value: settings.useArtColor,
onChanged: (v) => setState(() => settings.updateUseArtColor(v)), onChanged: (v) => setState(() => settings.updateUseArtColor(v)),
), ),
//Display mode 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) => 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( ListTile(
leading: const Icon(Icons.screen_lock_portrait), leading: const Icon(Icons.screen_lock_portrait),
title: Text('Change display mode'.i18n), title: Text('Change display mode'.i18n),

View file

@ -12,31 +12,107 @@ import 'cached_image.dart';
import 'dart:async'; import 'dart:async';
class TrackTile extends StatelessWidget { typedef SecondaryTapCallback = void Function(TapDownDetails?);
final Track track;
final void Function()? onTap;
final void Function()? onHold;
final Widget? trailing;
const TrackTile(this.track, class WrapSecondaryAction extends StatelessWidget {
{this.onTap, this.onHold, this.trailing, Key? key}) final SecondaryTapCallback? onSecondaryTapDown;
: super(key: key); final Widget child;
const WrapSecondaryAction(
{super.key, this.onSecondaryTapDown, required this.child});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListTile( 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?>( title: StreamBuilder<MediaItem?>(
stream: audioHandler.mediaItem, stream: audioHandler.mediaItem,
builder: (context, snapshot) { builder: (context, snapshot) {
final bool isHighlighted;
final mediaItem = snapshot.data; final mediaItem = snapshot.data;
if (!snapshot.hasData || snapshot.data == null) { final bool isHighlighted = mediaItem?.id == trackId;
isHighlighted = false;
} else {
isHighlighted = mediaItem!.id == track.id;
}
return Text( return Text(
track.title!, title,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.clip, overflow: TextOverflow.clip,
style: TextStyle( style: TextStyle(
@ -46,21 +122,23 @@ class TrackTile extends StatelessWidget {
); );
}), }),
subtitle: Text( subtitle: Text(
track.artistString, artist,
maxLines: 1, maxLines: 1,
), ),
leading: CachedImage( leading: CachedImage(
url: track.albumArt!.thumb, url: artUri,
width: 48.0, width: 48.0,
height: 48.0, height: 48.0,
), ),
onTap: onTap, onTap: onTap,
onLongPress: onHold, onLongPress: normalizeSecondary(onSecondary),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (checkTrackOffline)
FutureBuilder<bool>( FutureBuilder<bool>(
future: downloadManager.checkOffline(track: track), future:
downloadManager.checkOffline(track: Track(id: trackId)),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.data == true) { if (snapshot.data == true) {
return const Padding( return const Padding(
@ -74,7 +152,7 @@ class TrackTile extends StatelessWidget {
} }
return const SizedBox.shrink(); return const SizedBox.shrink();
}), }),
if (track.explicit ?? false) if (explicit)
const Padding( const Padding(
padding: EdgeInsets.symmetric(horizontal: 2.0), padding: EdgeInsets.symmetric(horizontal: 2.0),
child: Text( child: Text(
@ -85,13 +163,14 @@ class TrackTile extends StatelessWidget {
SizedBox( SizedBox(
width: 42.0, width: 42.0,
child: Text( child: Text(
track.durationString, durationString,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
if (trailing != null) trailing! if (trailing != null) trailing!
], ],
), ),
),
); );
} }
} }
@ -99,15 +178,19 @@ class TrackTile extends StatelessWidget {
class AlbumTile extends StatelessWidget { class AlbumTile extends StatelessWidget {
final Album? album; final Album? album;
final void Function()? onTap; final void Function()? onTap;
final void Function()? onHold;
/// Hold or Right click
final SecondaryTapCallback? onSecondary;
final Widget? trailing; final Widget? trailing;
const AlbumTile(this.album, const AlbumTile(this.album,
{super.key, this.onTap, this.onHold, this.trailing}); {super.key, this.onTap, this.onSecondary, this.trailing});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListTile( return WrapSecondaryAction(
onSecondaryTapDown: onSecondary,
child: ListTile(
title: Text( title: Text(
album!.title!, album!.title!,
maxLines: 1, maxLines: 1,
@ -121,8 +204,9 @@ class AlbumTile extends StatelessWidget {
width: 48, width: 48,
), ),
onTap: onTap, onTap: onTap,
onLongPress: onHold, onLongPress: normalizeSecondary(onSecondary),
trailing: trailing, trailing: trailing,
),
); );
} }
} }
@ -130,16 +214,19 @@ class AlbumTile extends StatelessWidget {
class ArtistTile extends StatelessWidget { class ArtistTile extends StatelessWidget {
final Artist? artist; final Artist? artist;
final void Function()? onTap; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return InkWell( return InkWell(
borderRadius: const BorderRadius.all(Radius.circular(4.0)), borderRadius: const BorderRadius.all(Radius.circular(4.0)),
onTap: onTap, onTap: onTap,
onLongPress: onHold, onLongPress: normalizeSecondary(onSecondary),
onSecondaryTapDown: onSecondary,
child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[ child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
const SizedBox(height: 4), const SizedBox(height: 4),
CachedImage( CachedImage(
@ -163,11 +250,11 @@ class ArtistTile extends StatelessWidget {
class PlaylistTile extends StatelessWidget { class PlaylistTile extends StatelessWidget {
final Playlist? playlist; final Playlist? playlist;
final void Function()? onTap; final void Function()? onTap;
final void Function()? onHold; final SecondaryTapCallback? onSecondary;
final Widget? trailing; final Widget? trailing;
const PlaylistTile(this.playlist, const PlaylistTile(this.playlist,
{super.key, this.onHold, this.onTap, this.trailing}); {super.key, this.onSecondary, this.onTap, this.trailing});
String? get subtitle { String? get subtitle {
if (playlist!.user == null || if (playlist!.user == null ||
@ -182,7 +269,9 @@ class PlaylistTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListTile( return WrapSecondaryAction(
onSecondaryTapDown: onSecondary,
child: ListTile(
title: Text( title: Text(
playlist!.title!, playlist!.title!,
maxLines: 1, maxLines: 1,
@ -196,8 +285,9 @@ class PlaylistTile extends StatelessWidget {
width: 48, width: 48,
), ),
onTap: onTap, onTap: onTap,
onLongPress: onHold, onLongPress: normalizeSecondary(onSecondary),
trailing: trailing, trailing: trailing,
),
); );
} }
} }

View file

@ -57,6 +57,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.18.12" 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: audio_service_platform_interface:
dependency: transitive dependency: transitive
description: description:

View file

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