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:
parent
6aa596177f
commit
6f1fb73ed8
|
@ -59,8 +59,7 @@ android {
|
|||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Put back signingConfig.release
|
||||
signingConfig signingConfigs.debug
|
||||
signingConfig signingConfigs.release
|
||||
shrinkResources false
|
||||
minifyEnabled true
|
||||
}
|
||||
|
|
|
@ -27,7 +27,7 @@ typedef _IsolateMessage = (
|
|||
class DeezerAudioSource extends StreamAudioSource {
|
||||
final _logger = Logger("DeezerAudioSource");
|
||||
|
||||
late AudioQuality? _quality;
|
||||
late AudioQuality Function() _getQuality;
|
||||
late AudioQuality? _initialQuality;
|
||||
late String _trackId;
|
||||
late String _md5origin;
|
||||
|
@ -35,24 +35,26 @@ class DeezerAudioSource extends StreamAudioSource {
|
|||
final StreamInfoCallback? onStreamObtained;
|
||||
|
||||
// some cache
|
||||
AudioQuality? _currentQuality;
|
||||
int? _cachedSourceLength;
|
||||
String? _cachedContentType;
|
||||
Uri? _downloadUrl;
|
||||
|
||||
DeezerAudioSource({
|
||||
required AudioQuality quality,
|
||||
required AudioQuality Function() getQuality,
|
||||
required String trackId,
|
||||
required String md5origin,
|
||||
required String mediaVersion,
|
||||
this.onStreamObtained,
|
||||
}) {
|
||||
_quality = quality;
|
||||
_getQuality = getQuality;
|
||||
_initialQuality = quality;
|
||||
_trackId = trackId;
|
||||
_md5origin = md5origin;
|
||||
_mediaVersion = mediaVersion;
|
||||
}
|
||||
|
||||
AudioQuality? get quality => _quality;
|
||||
AudioQuality? get quality => _currentQuality;
|
||||
String get trackId => _trackId;
|
||||
String get md5origin => _md5origin;
|
||||
String get mediaVersion => _mediaVersion;
|
||||
|
@ -71,7 +73,7 @@ class DeezerAudioSource extends StreamAudioSource {
|
|||
return await _qualityFallback();
|
||||
} on QualityException {
|
||||
_logger.warning("quality fallback failed! trying trackId fallback");
|
||||
_quality = _initialQuality;
|
||||
_currentQuality = _initialQuality;
|
||||
}
|
||||
|
||||
Map? privateJson;
|
||||
|
@ -128,16 +130,16 @@ class DeezerAudioSource extends StreamAudioSource {
|
|||
if (rc > 400) {
|
||||
_logger.warning(
|
||||
"quality fallback, response code: $rc, current quality: $quality");
|
||||
switch (_quality) {
|
||||
switch (_currentQuality) {
|
||||
case AudioQuality.FLAC:
|
||||
_quality = AudioQuality.MP3_320;
|
||||
_currentQuality = AudioQuality.MP3_320;
|
||||
break;
|
||||
case AudioQuality.MP3_320:
|
||||
_quality = AudioQuality.MP3_128;
|
||||
_currentQuality = AudioQuality.MP3_128;
|
||||
break;
|
||||
case AudioQuality.MP3_128:
|
||||
default:
|
||||
_quality = null;
|
||||
_currentQuality = null;
|
||||
throw QualityException("No quality to fallback to!");
|
||||
}
|
||||
|
||||
|
@ -220,7 +222,7 @@ class DeezerAudioSource extends StreamAudioSource {
|
|||
final deezerStart = start - dropBytes;
|
||||
int counter = deezerStart ~/ chunkSize;
|
||||
final buffer = List<int>.empty(growable: true);
|
||||
final key = await flutter.compute(getKey, trackId);
|
||||
final key = getKey(trackId);
|
||||
|
||||
await for (var bytes in source) {
|
||||
if (dropBytes > 0) {
|
||||
|
@ -277,12 +279,19 @@ class DeezerAudioSource extends StreamAudioSource {
|
|||
throw Exception("Authorization failed!");
|
||||
}
|
||||
|
||||
// determine quality to use
|
||||
_currentQuality = _getQuality!.call();
|
||||
|
||||
final Uri uri;
|
||||
if (_downloadUrl != null) {
|
||||
uri = _downloadUrl!;
|
||||
} else {
|
||||
try {
|
||||
uri = await _fallbackUrl();
|
||||
_downloadUrl = uri = await _fallbackUrl();
|
||||
} on QualityException {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
_logger.fine("Downloading track from ${uri.toString()}");
|
||||
final int deezerStart = start - (start % 2048);
|
||||
final req = http.Request('GET', uri)
|
||||
|
|
|
@ -80,8 +80,11 @@ class Track extends DeezerMediaItem {
|
|||
String get artistString => artists == null
|
||||
? ""
|
||||
: artists!.map<String?>((art) => art.name).join(', ');
|
||||
String get durationString =>
|
||||
"${duration!.inMinutes}:${duration!.inSeconds.remainder(60).toString().padLeft(2, '0')}";
|
||||
String get durationString => durationAsString(duration!);
|
||||
|
||||
static String durationAsString(Duration duration) {
|
||||
return "${duration.inMinutes}:${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}";
|
||||
}
|
||||
|
||||
//MediaItem
|
||||
Future<MediaItem> toMediaItem() async {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:disk_space_plus/disk_space_plus.dart';
|
||||
import 'package:filesize/filesize.dart';
|
||||
|
@ -22,7 +23,6 @@ class DownloadManager {
|
|||
// DownloadManager currently only supports android
|
||||
static bool get isSupported => Platform.isAndroid;
|
||||
|
||||
|
||||
//Platform channels
|
||||
static MethodChannel platform = const MethodChannel('f.f.freezer/native');
|
||||
static EventChannel eventChannel =
|
||||
|
@ -37,7 +37,7 @@ class DownloadManager {
|
|||
|
||||
//Start/Resume downloads
|
||||
Future start() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
if (!isSupported) return;
|
||||
|
||||
//Returns whether service is bound or not, the delay is really shitty/hacky way, until i find a real solution
|
||||
await updateServiceSettings();
|
||||
|
@ -46,13 +46,13 @@ class DownloadManager {
|
|||
|
||||
//Stop/Pause downloads
|
||||
Future stop() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
if (!isSupported) return;
|
||||
|
||||
await platform.invokeMethod('stop');
|
||||
}
|
||||
|
||||
Future init() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
if (!isSupported) return;
|
||||
//Remove old DB
|
||||
File oldDbFile = File(p.join((await getDatabasesPath()), 'offline.db'));
|
||||
if (await oldDbFile.exists()) {
|
||||
|
@ -100,7 +100,7 @@ class DownloadManager {
|
|||
|
||||
//Get all downloads from db
|
||||
Future<List<Download>> getDownloads() async {
|
||||
if (!Platform.isAndroid) return [];
|
||||
if (!isSupported) return [];
|
||||
|
||||
List raw = await platform.invokeMethod('getDownloads');
|
||||
return raw.map((d) => Download.fromJson(d)).toList();
|
||||
|
@ -158,7 +158,7 @@ class DownloadManager {
|
|||
|
||||
Future<bool> addOfflineTrack(Track track,
|
||||
{private = true, BuildContext? context, isSingleton = false}) async {
|
||||
if (!Platform.isAndroid) return false;
|
||||
if (!isSupported) return false;
|
||||
//Permission
|
||||
if (!private && !(await checkPermission())) return false;
|
||||
|
||||
|
@ -241,7 +241,7 @@ class DownloadManager {
|
|||
|
||||
Future addOfflinePlaylist(Playlist? playlist,
|
||||
{private = true, BuildContext? context, AudioQuality? quality}) async {
|
||||
if (!Platform.isAndroid) return false;
|
||||
if (!isSupported) return false;
|
||||
|
||||
//Permission
|
||||
if (!private && !(await checkPermission())) return;
|
||||
|
@ -338,7 +338,7 @@ class DownloadManager {
|
|||
|
||||
//Get all offline available tracks
|
||||
Future<List<Track?>> allOfflineTracks() async {
|
||||
if (!Platform.isAndroid) return [];
|
||||
if (!isSupported) return [];
|
||||
|
||||
List rawTracks =
|
||||
await db.query('Tracks', where: 'offline == 1', columns: ['id']);
|
||||
|
@ -352,6 +352,8 @@ class DownloadManager {
|
|||
|
||||
//Get all offline albums
|
||||
Future<List<Album>> getOfflineAlbums() async {
|
||||
if (!isSupported) return [];
|
||||
|
||||
List rawAlbums =
|
||||
await db.query('Albums', where: 'offline == 1', columns: ['id']);
|
||||
List<Album> out = [];
|
||||
|
@ -396,6 +398,7 @@ class DownloadManager {
|
|||
|
||||
//Get all offline playlists
|
||||
Future<List<Playlist>> getOfflinePlaylists() async {
|
||||
if (!isSupported) return [];
|
||||
final rawPlaylists = await db.query('Playlists', columns: ['id']);
|
||||
final out = <Playlist>[];
|
||||
for (final rawPlaylist in rawPlaylists) {
|
||||
|
@ -470,6 +473,7 @@ class DownloadManager {
|
|||
}
|
||||
|
||||
Future removeOfflinePlaylist(String? id) async {
|
||||
if (!isSupported) return;
|
||||
//Fetch playlist
|
||||
List rawPlaylists =
|
||||
await db.query('Playlists', where: 'id == ?', whereArgs: [id]);
|
||||
|
@ -481,9 +485,11 @@ class DownloadManager {
|
|||
}
|
||||
|
||||
//Check if album, track or playlist is offline
|
||||
Future<bool> checkOffline(
|
||||
{Album? album, Track? track, Playlist? playlist}) async {
|
||||
if (!Platform.isAndroid) return false;
|
||||
Future<bool> _checkOffline(
|
||||
(Album? album, Track? track, Playlist? playlist) message) async {
|
||||
if (!isSupported) return false;
|
||||
|
||||
final (album, track, playlist) = message;
|
||||
|
||||
//Track
|
||||
if (track != null) {
|
||||
|
@ -509,6 +515,10 @@ class DownloadManager {
|
|||
return false;
|
||||
}
|
||||
|
||||
Future<bool> checkOffline({Album? album, Track? track, Playlist? playlist}) {
|
||||
return compute(_checkOffline, (album, track, playlist));
|
||||
}
|
||||
|
||||
//Offline search
|
||||
Future<SearchResults> search(String? query) async {
|
||||
SearchResults results =
|
||||
|
@ -619,7 +629,7 @@ class DownloadManager {
|
|||
|
||||
//Send settings to download service
|
||||
Future updateServiceSettings() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
if (!isSupported) return;
|
||||
await platform.invokeMethod(
|
||||
'updateSettings', settings.getServiceSettings());
|
||||
}
|
||||
|
@ -639,21 +649,21 @@ class DownloadManager {
|
|||
|
||||
//Remove download from queue/finished
|
||||
Future removeDownload(int? id) async {
|
||||
if (!Platform.isAndroid) return;
|
||||
if (!isSupported) return;
|
||||
|
||||
await platform.invokeMethod('removeDownload', {'id': id});
|
||||
}
|
||||
|
||||
//Restart failed downloads
|
||||
Future retryDownloads() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
if (!isSupported) return;
|
||||
|
||||
await platform.invokeMethod('retryDownloads');
|
||||
}
|
||||
|
||||
//Delete downloads by state
|
||||
Future removeDownloads(DownloadState state) async {
|
||||
if (!Platform.isAndroid) return;
|
||||
if (!isSupported) return;
|
||||
|
||||
await platform.invokeMethod(
|
||||
'removeDownloads', {'state': DownloadState.values.indexOf(state)});
|
||||
|
|
|
@ -354,6 +354,8 @@ class AudioPlayerTaskInitArguments {
|
|||
}
|
||||
|
||||
class AudioPlayerTask extends BaseAudioHandler {
|
||||
final _logger = Logger('AudioPlayerTask');
|
||||
|
||||
late AudioPlayer _player;
|
||||
late ConcatenatingAudioSource _audioSource;
|
||||
late DeezerAPI _deezerAPI;
|
||||
|
@ -376,6 +378,7 @@ class AudioPlayerTask extends BaseAudioHandler {
|
|||
StreamSubscription? _bufferPositionSubscription;
|
||||
StreamSubscription? _audioSessionSubscription;
|
||||
StreamSubscription? _visualizerSubscription;
|
||||
StreamSubscription? _connectivitySubscription;
|
||||
|
||||
/// Android Auto helper class for navigation
|
||||
late final AndroidAuto _androidAuto;
|
||||
|
@ -384,6 +387,8 @@ class AudioPlayerTask extends BaseAudioHandler {
|
|||
AudioQuality mobileQuality = AudioQuality.MP3_128;
|
||||
AudioQuality wifiQuality = AudioQuality.MP3_128;
|
||||
|
||||
AudioQuality _currentQuality = AudioQuality.MP3_128;
|
||||
|
||||
/// Current queueSource (=> where playback has begun from)
|
||||
QueueSource? queueSource;
|
||||
|
||||
|
@ -466,7 +471,7 @@ class AudioPlayerTask extends BaseAudioHandler {
|
|||
//Update
|
||||
_broadcastState();
|
||||
}, onError: (Object e, StackTrace st) {
|
||||
print('A stream error occurred: $e');
|
||||
_logger.severe('A stream error occurred: $e');
|
||||
});
|
||||
_player.processingStateStream.listen((state) {
|
||||
switch (state) {
|
||||
|
@ -496,6 +501,20 @@ class AudioPlayerTask extends BaseAudioHandler {
|
|||
//Load queue
|
||||
// queue.add(_queue);
|
||||
|
||||
// Determine audio quality to use
|
||||
try {
|
||||
await Connectivity().checkConnectivity().then(_determineAudioQuality);
|
||||
|
||||
// listen for connectivity changes
|
||||
_connectivitySubscription =
|
||||
Connectivity().onConnectivityChanged.listen(_determineAudioQuality);
|
||||
} catch (e) {
|
||||
_logger.warning(
|
||||
'Couldn\'t determine connection! Falling back to other (which may use wifi quality)');
|
||||
// on error, return dummy value -- error can happen on linux if not using NetworkManager, for example
|
||||
_determineAudioQuality(ConnectivityResult.other);
|
||||
}
|
||||
|
||||
await _loadQueueFile();
|
||||
|
||||
if (initArgs.lastFMUsername != null && initArgs.lastFMPassword != null) {
|
||||
|
@ -506,6 +525,21 @@ class AudioPlayerTask extends BaseAudioHandler {
|
|||
customEvent.add({'action': 'onLoad'});
|
||||
}
|
||||
|
||||
void _determineAudioQuality(ConnectivityResult result) {
|
||||
switch (result) {
|
||||
case ConnectivityResult.mobile:
|
||||
case ConnectivityResult.bluetooth:
|
||||
_currentQuality = mobileQuality;
|
||||
case ConnectivityResult.other:
|
||||
_currentQuality =
|
||||
Platform.isLinux || Platform.isLinux || Platform.isMacOS
|
||||
? wifiQuality
|
||||
: mobileQuality;
|
||||
default:
|
||||
_currentQuality = wifiQuality;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future skipToQueueItem(int index) async {
|
||||
_lastPosition = null;
|
||||
|
@ -772,7 +806,9 @@ class AudioPlayerTask extends BaseAudioHandler {
|
|||
if (!queue.hasValue || queue.value.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final sources = await Future.wait(queue.value.map(_mediaItemToAudioSource));
|
||||
|
||||
final sources =
|
||||
await Future.wait(queue.value.map((e) => _mediaItemToAudioSource(e)));
|
||||
|
||||
_audioSource = ConcatenatingAudioSource(
|
||||
children: sources,
|
||||
|
@ -827,10 +863,6 @@ class AudioPlayerTask extends BaseAudioHandler {
|
|||
//Due to current limitations of just_audio, quality fallback moved to DeezerDataSource in ExoPlayer
|
||||
//This just returns fake url that contains metadata
|
||||
List? playbackDetails = jsonDecode(mediaItem.extras!['playbackDetails']);
|
||||
//Quality
|
||||
ConnectivityResult conn = await Connectivity().checkConnectivity();
|
||||
AudioQuality quality = mobileQuality;
|
||||
if (conn == ConnectivityResult.wifi) quality = wifiQuality;
|
||||
|
||||
if ((playbackDetails ?? []).length < 2) {
|
||||
throw Exception('not enough playback details');
|
||||
|
@ -848,7 +880,7 @@ class AudioPlayerTask extends BaseAudioHandler {
|
|||
|
||||
// DON'T use the java backend anymore, useless.
|
||||
return DeezerAudioSource(
|
||||
quality: quality,
|
||||
getQuality: () => _currentQuality,
|
||||
trackId: mediaItem.id,
|
||||
md5origin: playbackDetails![0],
|
||||
mediaVersion: playbackDetails[1],
|
||||
|
|
|
@ -150,9 +150,7 @@ class _FreezerAppState extends State<FreezerApp> {
|
|||
}
|
||||
|
||||
void _updateTheme() {
|
||||
setState(() {
|
||||
settings.themeData;
|
||||
});
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Locale? _locale() {
|
||||
|
@ -317,9 +315,8 @@ class MainScreenState extends State<MainScreen>
|
|||
final playerScreenFocusNode = FocusScopeNode();
|
||||
final playerBarFocusNode = FocusNode();
|
||||
final _fancyScaffoldKey = GlobalKey<FancyScaffoldState>();
|
||||
final routeObserver = RouteObserver();
|
||||
|
||||
late bool _isDesktop;
|
||||
late bool isDesktop;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
@ -647,11 +644,11 @@ class MainScreenState extends State<MainScreen>
|
|||
child: LayoutBuilder(builder: (context, constraints) {
|
||||
// check if we're running on a desktop platform
|
||||
final isLandscape = constraints.maxWidth > constraints.maxHeight;
|
||||
_isDesktop = isLandscape && constraints.maxWidth > 1024;
|
||||
isDesktop = isLandscape && constraints.maxWidth > 1024;
|
||||
return FancyScaffold(
|
||||
key: _fancyScaffoldKey,
|
||||
bodyDrawer: _buildNavigationRail(_isDesktop),
|
||||
bottomNavigationBar: buildBottomBar(_isDesktop),
|
||||
bodyDrawer: _buildNavigationRail(isDesktop),
|
||||
bottomNavigationBar: buildBottomBar(isDesktop),
|
||||
bottomPanel: PlayerBar(
|
||||
focusNode: playerBarFocusNode,
|
||||
onTap: () =>
|
||||
|
@ -678,7 +675,6 @@ class MainScreenState extends State<MainScreen>
|
|||
skipTraversal: true,
|
||||
canRequestFocus: false,
|
||||
child: _MainRouteNavigator(
|
||||
observers: [routeObserver],
|
||||
navigatorKey: navigatorKey,
|
||||
routes: {
|
||||
Navigator.defaultRouteName: (context) =>
|
||||
|
@ -805,16 +801,24 @@ class _ExtensibleNavigationRailState extends State<ExtensibleNavigationRail> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _extended = true),
|
||||
onExit: (_) => setState(() => _extended = false),
|
||||
child: NavigationRail(
|
||||
final child = NavigationRail(
|
||||
extended: _extended,
|
||||
destinations: widget.destinations,
|
||||
selectedIndex: widget.selectedIndex,
|
||||
onDestinationSelected: widget.onDestinationSelected,
|
||||
),
|
||||
);
|
||||
|
||||
if (settings.navigationRailAppearance !=
|
||||
NavigationRailAppearance.expand_on_hover) {
|
||||
_extended = settings.navigationRailAppearance ==
|
||||
NavigationRailAppearance.always_expanded;
|
||||
return child;
|
||||
}
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _extended = true),
|
||||
onExit: (_) => setState(() => _extended = false),
|
||||
child: child);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,11 @@ import 'package:freezer/page_routes/basic_page_route.dart';
|
|||
import 'package:freezer/ui/animated_blur.dart';
|
||||
|
||||
class FadePageRoute<T> extends BasicPageRoute<T> {
|
||||
@override
|
||||
final bool barrierDismissible;
|
||||
@override
|
||||
final Color? barrierColor;
|
||||
|
||||
final WidgetBuilder builder;
|
||||
final bool blur;
|
||||
FadePageRoute({
|
||||
|
@ -11,6 +16,8 @@ class FadePageRoute<T> extends BasicPageRoute<T> {
|
|||
super.transitionDuration,
|
||||
super.maintainState,
|
||||
super.settings,
|
||||
this.barrierColor,
|
||||
this.barrierDismissible = false,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
|
@ -166,6 +166,10 @@ class Settings {
|
|||
@HiveField(47, defaultValue: false)
|
||||
bool seekAsSkip = false;
|
||||
|
||||
@HiveField(48, defaultValue: NavigationRailAppearance.expand_on_hover)
|
||||
NavigationRailAppearance navigationRailAppearance =
|
||||
NavigationRailAppearance.expand_on_hover;
|
||||
|
||||
static LazyBox<Settings>? __box;
|
||||
static Future<LazyBox<Settings>> get _box async =>
|
||||
__box ??= await Hive.openLazyBox<Settings>('settings');
|
||||
|
@ -415,3 +419,13 @@ class SpotifyCredentialsSave {
|
|||
_$SpotifyCredentialsSaveFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$SpotifyCredentialsSaveToJson(this);
|
||||
}
|
||||
|
||||
@HiveType(typeId: 34)
|
||||
enum NavigationRailAppearance {
|
||||
@HiveField(0)
|
||||
expand_on_hover,
|
||||
@HiveField(1)
|
||||
always_expanded,
|
||||
@HiveField(2)
|
||||
icons_only,
|
||||
}
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:freezer/page_routes/fade.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
|
@ -103,14 +105,14 @@ class _CachedImageState extends State<CachedImage> {
|
|||
}
|
||||
}
|
||||
|
||||
class ZoomableImage extends StatefulWidget {
|
||||
class ZoomableImage extends StatelessWidget {
|
||||
final String url;
|
||||
final bool rounded;
|
||||
final double? width;
|
||||
final bool enableHero;
|
||||
final Object? heroTag;
|
||||
|
||||
const ZoomableImage({
|
||||
ZoomableImage({
|
||||
super.key,
|
||||
required this.url,
|
||||
this.rounded = false,
|
||||
|
@ -119,38 +121,15 @@ class ZoomableImage extends StatefulWidget {
|
|||
this.heroTag,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ZoomableImage> createState() => _ZoomableImageState();
|
||||
}
|
||||
|
||||
class _ZoomableImageState extends State<ZoomableImage> {
|
||||
PhotoViewController? controller;
|
||||
bool photoViewOpened = false;
|
||||
late final Object? _key =
|
||||
widget.enableHero ? (widget.heroTag ?? UniqueKey()) : null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = PhotoViewController()..outputStateStream.listen(listener);
|
||||
}
|
||||
|
||||
// Listener of PhotoView scale changes. Used for closing PhotoView by pinch-in
|
||||
void listener(PhotoViewControllerValue value) {
|
||||
if (value.scale! < 0.16 && photoViewOpened) {
|
||||
Navigator.pop(context);
|
||||
photoViewOpened =
|
||||
false; // to avoid multiple pop() when picture are being scaled out too slowly
|
||||
}
|
||||
}
|
||||
late final Object? _key = enableHero ? (heroTag ?? UniqueKey()) : null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
print('key: $_key');
|
||||
final image = CachedImage(
|
||||
url: widget.url,
|
||||
rounded: widget.rounded,
|
||||
width: widget.width,
|
||||
url: url,
|
||||
rounded: rounded,
|
||||
width: width,
|
||||
fullThumb: true,
|
||||
);
|
||||
final child = _key != null
|
||||
|
@ -165,25 +144,72 @@ class _ZoomableImageState extends State<ZoomableImage> {
|
|||
child: child,
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(PageRouteBuilder(
|
||||
opaque: false, // transparent background
|
||||
pageBuilder: (context, animation, __) {
|
||||
photoViewOpened = true;
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: PhotoView(
|
||||
imageProvider: CachedNetworkImageProvider(widget.url),
|
||||
maxScale: 8.0,
|
||||
minScale: 0.2,
|
||||
controller: controller,
|
||||
heroAttributes: _key == null
|
||||
? null
|
||||
: PhotoViewHeroAttributes(tag: _key!),
|
||||
backgroundDecoration: const BoxDecoration(
|
||||
color: Color.fromARGB(0x90, 0, 0, 0))),
|
||||
);
|
||||
}));
|
||||
Navigator.of(context).push(FadePageRoute(
|
||||
builder: (context) =>
|
||||
ZoomableImageRoute(imageUrl: url, heroKey: _key),
|
||||
barrierDismissible: true));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ZoomableImageRoute extends StatefulWidget {
|
||||
final Object? heroKey;
|
||||
final String imageUrl;
|
||||
const ZoomableImageRoute({required this.imageUrl, super.key, this.heroKey});
|
||||
|
||||
@override
|
||||
State<ZoomableImageRoute> createState() => _ZoomableImageRouteState();
|
||||
}
|
||||
|
||||
class _ZoomableImageRouteState extends State<ZoomableImageRoute> {
|
||||
bool photoViewOpened = false;
|
||||
final controller = PhotoViewController();
|
||||
final _focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
controller.outputStateStream.listen(listener);
|
||||
_focusNode.requestFocus();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void listener(PhotoViewControllerValue value) {
|
||||
if (value.scale! < 0.16 && photoViewOpened) {
|
||||
Navigator.pop(context);
|
||||
photoViewOpened =
|
||||
false; // to avoid multiple pop() when picture are being scaled out too slowly
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RawKeyboardListener(
|
||||
focusNode: _focusNode,
|
||||
onKey: (event) {
|
||||
if (event is! KeyUpEvent) return;
|
||||
|
||||
if (event.isKeyPressed(LogicalKeyboardKey.escape)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: PhotoView(
|
||||
imageProvider: CachedNetworkImageProvider(widget.imageUrl),
|
||||
maxScale: 8.0,
|
||||
minScale: 0.2,
|
||||
controller: controller,
|
||||
heroAttributes: widget.heroKey == null
|
||||
? null
|
||||
: PhotoViewHeroAttributes(tag: widget.heroKey!),
|
||||
backgroundDecoration:
|
||||
const BoxDecoration(color: Color.fromARGB(0x90, 0, 0, 0))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:draggable_scrollbar/draggable_scrollbar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:fluttericon/font_awesome5_icons.dart';
|
||||
import 'package:freezer/api/cache.dart';
|
||||
import 'package:freezer/api/deezer.dart';
|
||||
|
@ -77,11 +79,14 @@ class _AlbumDetailsState extends State<AlbumDetails> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
const SizedBox(height: 8.0),
|
||||
ZoomableImage(
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints.loose(
|
||||
MediaQuery.of(context).size / 3),
|
||||
child: ZoomableImage(
|
||||
url: album!.art!.full,
|
||||
width: MediaQuery.of(context).size.width / 2,
|
||||
rounded: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8.0),
|
||||
Text(
|
||||
album!.title!,
|
||||
|
@ -228,12 +233,14 @@ class _AlbumDetailsState extends State<AlbumDetails> {
|
|||
),
|
||||
...List.generate(
|
||||
tracks.length,
|
||||
(i) => TrackTile(tracks[i]!, onTap: () {
|
||||
(i) =>
|
||||
TrackTile.fromTrack(tracks[i]!, onTap: () {
|
||||
playerHelper.playFromAlbum(
|
||||
album!, tracks[i]!.id);
|
||||
}, onHold: () {
|
||||
}, onSecondary: (details) {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultTrackMenu(tracks[i]!);
|
||||
m.defaultTrackMenu(tracks[i]!,
|
||||
details: details);
|
||||
}))
|
||||
],
|
||||
);
|
||||
|
@ -299,18 +306,25 @@ class _MakeAlbumOfflineState extends State<MakeAlbumOffline> {
|
|||
}
|
||||
}
|
||||
|
||||
class ArtistDetails extends StatelessWidget {
|
||||
late final Artist artist;
|
||||
late final Future? _future;
|
||||
class ArtistDetails extends StatefulWidget {
|
||||
final Artist artist;
|
||||
|
||||
ArtistDetails(Artist artist, {Key? key}) : super(key: key) {
|
||||
FutureOr<Artist> future = _loadArtist(artist);
|
||||
if (future is Artist) {
|
||||
this.artist = future;
|
||||
_future = null;
|
||||
} else {
|
||||
_future = future.then((value) => this.artist = value);
|
||||
const ArtistDetails(this.artist, {super.key});
|
||||
|
||||
@override
|
||||
State<ArtistDetails> createState() => _ArtistDetailsState();
|
||||
}
|
||||
|
||||
class _ArtistDetailsState extends State<ArtistDetails> {
|
||||
late final Future<Artist> _future;
|
||||
void initState() {
|
||||
FutureOr<Artist> future = _loadArtist(widget.artist);
|
||||
if (future is Artist) {
|
||||
_future = Future.value(widget.artist);
|
||||
} else {
|
||||
_future = future;
|
||||
}
|
||||
super.initState();
|
||||
}
|
||||
|
||||
FutureOr<Artist> _loadArtist(Artist artist) {
|
||||
|
@ -318,44 +332,53 @@ class ArtistDetails extends StatelessWidget {
|
|||
if ((artist.albums ?? []).isEmpty) {
|
||||
return deezerAPI.artist(artist.id);
|
||||
}
|
||||
|
||||
return artist;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: FutureBuilder(
|
||||
future: _future ?? Future.value(),
|
||||
builder: (BuildContext context, AsyncSnapshot snapshot) {
|
||||
body: FutureBuilder<Artist>(
|
||||
future: _future,
|
||||
builder: (BuildContext context, snapshot) {
|
||||
//Error / not done
|
||||
if (snapshot.hasError) return const ErrorScreen();
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final artist = snapshot.data!;
|
||||
|
||||
return ListView(
|
||||
children: <Widget>[
|
||||
const SizedBox(height: 4.0),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height / 3,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
ZoomableImage(
|
||||
url: artist.picture!.full,
|
||||
width: MediaQuery.of(context).size.width / 2 - 8,
|
||||
Flexible(
|
||||
child: ZoomableImage(
|
||||
url: widget.artist.picture!.full,
|
||||
rounded: true,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width / 2 - 24,
|
||||
width: min(
|
||||
MediaQuery.of(context).size.width / 16, 60.0)),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
artist.name!,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 4,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 24.0, fontWeight: FontWeight.bold),
|
||||
),
|
||||
|
@ -386,7 +409,7 @@ class ArtistDetails extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(width: 8.0),
|
||||
Text(
|
||||
artist.albumCount.toString(),
|
||||
widget.artist.albumCount.toString(),
|
||||
style: const TextStyle(fontSize: 16),
|
||||
)
|
||||
],
|
||||
|
@ -396,7 +419,8 @@ class ArtistDetails extends StatelessWidget {
|
|||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4.0),
|
||||
),
|
||||
),
|
||||
const FreezerDivider(),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
|
@ -411,7 +435,7 @@ class ArtistDetails extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
onPressed: () async {
|
||||
await deezerAPI.addFavoriteArtist(artist.id);
|
||||
await deezerAPI.addFavoriteArtist(widget.artist.id);
|
||||
ScaffoldMessenger.of(context)
|
||||
.snack('Added to library'.i18n);
|
||||
},
|
||||
|
@ -485,15 +509,15 @@ class ArtistDetails extends StatelessWidget {
|
|||
return const SizedBox(height: 0.0, width: 0.0);
|
||||
}
|
||||
Track t = artist.topTracks![i];
|
||||
return TrackTile(
|
||||
return TrackTile.fromTrack(
|
||||
t,
|
||||
onTap: () {
|
||||
playerHelper.playFromTopTracks(
|
||||
artist.topTracks!, t.id, artist);
|
||||
},
|
||||
onHold: () {
|
||||
onSecondary: (details) {
|
||||
MenuSheet mi = MenuSheet(context);
|
||||
mi.defaultTrackMenu(t);
|
||||
mi.defaultTrackMenu(t, details: details);
|
||||
},
|
||||
);
|
||||
}),
|
||||
|
@ -542,9 +566,9 @@ class ArtistDetails extends StatelessWidget {
|
|||
Navigator.of(context)
|
||||
.pushRoute(builder: (context) => AlbumDetails(a));
|
||||
},
|
||||
onHold: () {
|
||||
onSecondary: (details) {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultAlbumMenu(a);
|
||||
m.defaultAlbumMenu(a, details: details);
|
||||
},
|
||||
);
|
||||
})
|
||||
|
@ -603,9 +627,9 @@ class _DiscographyScreenState extends State<DiscographyScreen> {
|
|||
a,
|
||||
onTap: () => Navigator.of(context)
|
||||
.push(MaterialPageRoute(builder: (context) => AlbumDetails(a))),
|
||||
onHold: () {
|
||||
onSecondary: (details) {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultAlbumMenu(a);
|
||||
m.defaultAlbumMenu(a, details: details);
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -857,57 +881,59 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
|
|||
child: ListView(
|
||||
controller: _scrollController,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
height: 4.0,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
|
||||
const SizedBox(height: 4.0),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints.tight(
|
||||
Size.fromHeight(MediaQuery.of(context).size.height / 3)),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: <Widget>[
|
||||
CachedImage(
|
||||
Flexible(
|
||||
child: CachedImage(
|
||||
url: playlist!.image!.full,
|
||||
height: MediaQuery.of(context).size.width / 2 - 8,
|
||||
rounded: true,
|
||||
fullThumb: true,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width / 2 - 8,
|
||||
width: min(MediaQuery.of(context).size.width / 16, 60.0)),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
playlist!.title!,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
textAlign: TextAlign.start,
|
||||
maxLines: 3,
|
||||
style: const TextStyle(
|
||||
fontSize: 20.0, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Container(height: 4.0),
|
||||
Text(
|
||||
playlist!.user!.name ?? '',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 2,
|
||||
textAlign: TextAlign.center,
|
||||
textAlign: TextAlign.start,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontSize: 17.0),
|
||||
),
|
||||
Container(height: 10.0),
|
||||
const SizedBox(height: 16.0),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.audiotrack,
|
||||
size: 32.0,
|
||||
size: 20.0,
|
||||
semanticLabel: "Tracks".i18n,
|
||||
),
|
||||
Container(
|
||||
width: 8.0,
|
||||
),
|
||||
const SizedBox(width: 8.0),
|
||||
Text(
|
||||
(playlist!.trackCount ?? playlist!.tracks!.length)
|
||||
.toString(),
|
||||
|
@ -915,6 +941,7 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
|
|||
)
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6.0),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
|
@ -923,9 +950,7 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
|
|||
size: 32.0,
|
||||
semanticLabel: "Duration".i18n,
|
||||
),
|
||||
Container(
|
||||
width: 8.0,
|
||||
),
|
||||
const SizedBox(width: 8.0),
|
||||
Text(
|
||||
playlist!.durationString,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
|
@ -934,10 +959,11 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
|
|||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (playlist!.description != null &&
|
||||
playlist!.description!.isNotEmpty)
|
||||
const FreezerDivider(),
|
||||
|
@ -1057,13 +1083,13 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
|
|||
const FreezerDivider(),
|
||||
...List.generate(playlist!.tracks!.length, (i) {
|
||||
Track t = sorted[i];
|
||||
return TrackTile(t, onTap: () {
|
||||
return TrackTile.fromTrack(t, onTap: () {
|
||||
Playlist p = Playlist(
|
||||
title: playlist!.title, id: playlist!.id, tracks: sorted);
|
||||
playerHelper.playFromPlaylist(p, t.id);
|
||||
}, onHold: () {
|
||||
}, onSecondary: (details) {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultTrackMenu(t, options: [
|
||||
m.defaultTrackMenu(t, details: details, options: [
|
||||
(playlist!.user!.id == deezerAPI.userId)
|
||||
? m.removeFromPlaylist(t, playlist)
|
||||
: const SizedBox(
|
||||
|
@ -1138,9 +1164,7 @@ class _MakePlaylistOfflineState extends State<MakePlaylistOffline> {
|
|||
});
|
||||
},
|
||||
),
|
||||
Container(
|
||||
width: 4.0,
|
||||
),
|
||||
const SizedBox(width: 4.0),
|
||||
Text(
|
||||
'Offline'.i18n,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
|
|
|
@ -328,9 +328,9 @@ class HomePageItemWidget extends StatelessWidget {
|
|||
Navigator.of(context)
|
||||
.pushRoute(builder: (context) => ArtistDetails(item.value));
|
||||
},
|
||||
onHold: () {
|
||||
onSecondary: (details) {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultArtistMenu(item.value);
|
||||
m.defaultArtistMenu(item.value, details: details);
|
||||
},
|
||||
);
|
||||
case HomePageItemType.PLAYLIST:
|
||||
|
|
|
@ -168,7 +168,8 @@ class LibraryScreen extends StatelessWidget {
|
|||
if (DownloadManager.isSupported)
|
||||
ExpansionTile(
|
||||
title: Text('Statistics'.i18n),
|
||||
leading: const LeadingIcon(Icons.insert_chart, color: Colors.grey),
|
||||
leading:
|
||||
const LeadingIcon(Icons.insert_chart, color: Colors.grey),
|
||||
children: <Widget>[
|
||||
FutureBuilder(
|
||||
future: downloadManager.getStats(),
|
||||
|
@ -501,7 +502,7 @@ class _LibraryTracksState extends State<LibraryTracks> {
|
|||
Track? t = (tracks.length == (trackCount ?? 0))
|
||||
? _sorted[i]
|
||||
: tracks[i];
|
||||
return TrackTile(
|
||||
return TrackTile.fromTrack(
|
||||
t,
|
||||
onTap: () {
|
||||
playerHelper.playFromTrackList(
|
||||
|
@ -514,9 +515,9 @@ class _LibraryTracksState extends State<LibraryTracks> {
|
|||
text: 'Favorites'.i18n,
|
||||
source: 'playlist'));
|
||||
},
|
||||
onHold: () {
|
||||
onSecondary: (details) {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultTrackMenu(t, onRemove: () {
|
||||
m.defaultTrackMenu(t, details: details, onRemove: () {
|
||||
setState(() {
|
||||
tracks.removeWhere((track) => t.id == track.id);
|
||||
});
|
||||
|
@ -533,7 +534,7 @@ class _LibraryTracksState extends State<LibraryTracks> {
|
|||
),
|
||||
const SizedBox(height: 8.0),
|
||||
for (final track in allTracks)
|
||||
TrackTile(track!, onTap: () {
|
||||
TrackTile.fromTrack(track!, onTap: () {
|
||||
playerHelper.playFromTrackList(
|
||||
allTracks,
|
||||
track.id,
|
||||
|
@ -541,8 +542,9 @@ class _LibraryTracksState extends State<LibraryTracks> {
|
|||
id: 'allTracks',
|
||||
text: 'All offline tracks'.i18n,
|
||||
source: 'offline'));
|
||||
}, onHold: () {
|
||||
MenuSheet(context).defaultTrackMenu(track);
|
||||
}, onSecondary: (details) {
|
||||
MenuSheet(context)
|
||||
.defaultTrackMenu(track, details: details);
|
||||
}),
|
||||
],
|
||||
)));
|
||||
|
@ -689,11 +691,15 @@ class _LibraryAlbumsState extends State<LibraryAlbums> {
|
|||
Navigator.of(context).pushRoute(
|
||||
builder: (context) => AlbumDetails(a));
|
||||
},
|
||||
onHold: () async {
|
||||
onSecondary: (details) {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultAlbumMenu(a, onRemove: () {
|
||||
m.defaultAlbumMenu(
|
||||
a,
|
||||
details: details,
|
||||
onRemove: () {
|
||||
setState(() => _albums!.remove(a));
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
|
@ -727,9 +733,10 @@ class _LibraryAlbumsState extends State<LibraryAlbums> {
|
|||
Navigator.of(context).pushRoute(
|
||||
builder: (context) => AlbumDetails(a));
|
||||
},
|
||||
onHold: () async {
|
||||
onSecondary: (details) {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultAlbumMenu(a, onRemove: () {
|
||||
m.defaultAlbumMenu(a, details: details,
|
||||
onRemove: () {
|
||||
setState(() {
|
||||
albums.remove(a);
|
||||
_albums!.remove(a);
|
||||
|
@ -1090,10 +1097,10 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
|
|||
Navigator.of(context).pushRoute(
|
||||
builder: (context) => PlaylistDetails(favoritesPlaylist));
|
||||
},
|
||||
onHold: () {
|
||||
onSecondary: (details) {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
favoritesPlaylist.library = true;
|
||||
m.defaultPlaylistMenu(favoritesPlaylist);
|
||||
m.defaultPlaylistMenu(favoritesPlaylist, details: details);
|
||||
},
|
||||
),
|
||||
|
||||
|
@ -1104,9 +1111,9 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
|
|||
p,
|
||||
onTap: () => Navigator.of(context)
|
||||
.pushRoute(builder: (context) => PlaylistDetails(p)),
|
||||
onHold: () {
|
||||
onSecondary: (details) {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultPlaylistMenu(p, onRemove: () {
|
||||
m.defaultPlaylistMenu(p, details: details, onRemove: () {
|
||||
setState(() => _playlists!.remove(p));
|
||||
}, onUpdate: () {
|
||||
_load();
|
||||
|
@ -1140,9 +1147,10 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
|
|||
p,
|
||||
onTap: () => Navigator.of(context).pushRoute(
|
||||
builder: (context) => PlaylistDetails(p)),
|
||||
onHold: () {
|
||||
onSecondary: (details) {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultPlaylistMenu(p, onRemove: () {
|
||||
m.defaultPlaylistMenu(p, details: details,
|
||||
onRemove: () {
|
||||
setState(() {
|
||||
playlists.remove(p);
|
||||
_playlists!.remove(p);
|
||||
|
@ -1196,7 +1204,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||
itemCount: cache.history.length,
|
||||
itemBuilder: (BuildContext context, int i) {
|
||||
Track t = cache.history[cache.history.length - i - 1];
|
||||
return TrackTile(
|
||||
return TrackTile.fromTrack(
|
||||
t,
|
||||
onTap: () {
|
||||
playerHelper.playFromTrackList(
|
||||
|
@ -1205,9 +1213,9 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||
QueueSource(
|
||||
id: null, text: 'History'.i18n, source: 'history'));
|
||||
},
|
||||
onHold: () {
|
||||
onSecondary: (details) {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultTrackMenu(t);
|
||||
m.defaultTrackMenu(t, details: details);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
|
|
@ -13,20 +13,45 @@ import 'package:freezer/ui/error.dart';
|
|||
import 'package:freezer/ui/player_bar.dart';
|
||||
import 'package:freezer/ui/player_screen.dart';
|
||||
|
||||
class LyricsScreen extends StatefulWidget {
|
||||
const LyricsScreen({Key? key}) : super(key: key);
|
||||
class LyricsScreen extends StatelessWidget {
|
||||
const LyricsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LyricsScreen> createState() => _LyricsScreenState();
|
||||
Widget build(BuildContext context) {
|
||||
return PlayerScreenBackground(
|
||||
enabled: settings.playerBackgroundOnLyrics,
|
||||
appBar: AppBar(
|
||||
title: Text('Lyrics'.i18n),
|
||||
systemOverlayStyle: PlayerScreenBackground.getSystemUiOverlayStyle(
|
||||
context,
|
||||
enabled: settings.playerBackgroundOnLyrics),
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
child: const Column(
|
||||
children: [
|
||||
LyricsWidget(),
|
||||
Divider(height: 1.0, thickness: 1.0),
|
||||
PlayerBar(backgroundColor: Colors.transparent),
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
class _LyricsScreenState extends State<LyricsScreen> {
|
||||
class LyricsWidget extends StatefulWidget {
|
||||
const LyricsWidget({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<LyricsWidget> createState() => _LyricsWidgetState();
|
||||
}
|
||||
|
||||
class _LyricsWidgetState extends State<LyricsWidget> {
|
||||
late StreamSubscription _mediaItemSub;
|
||||
late StreamSubscription _playbackStateSub;
|
||||
int? _currentIndex = -1;
|
||||
int? _prevIndex = -1;
|
||||
final ScrollController _controller = ScrollController();
|
||||
final double height = 90;
|
||||
BoxConstraints? _widgetConstraints;
|
||||
Lyrics? _lyrics;
|
||||
bool _loading = true;
|
||||
CancelableOperation<Lyrics>? _lyricsCancelable;
|
||||
|
@ -60,7 +85,9 @@ class _LyricsScreenState extends State<LyricsScreen> {
|
|||
_loading = false;
|
||||
_lyrics = lyrics;
|
||||
});
|
||||
_scrollToLyric();
|
||||
|
||||
SchedulerBinding.instance.addPostFrameCallback(
|
||||
(_) => _updatePosition(audioHandler.playbackState.value.position));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
|
@ -72,8 +99,15 @@ class _LyricsScreenState extends State<LyricsScreen> {
|
|||
Future<void> _scrollToLyric() async {
|
||||
if (!_controller.hasClients) return;
|
||||
//Lyric height, screen height, appbar height
|
||||
double scrollTo = (height * _currentIndex!) -
|
||||
double scrollTo;
|
||||
if (_widgetConstraints == null) {
|
||||
scrollTo = (height * _currentIndex!) -
|
||||
(MediaQuery.of(context).size.height / 4 + height / 2);
|
||||
} else {
|
||||
final widgetHeight = _widgetConstraints!.maxHeight;
|
||||
final minScroll = height * _currentIndex!;
|
||||
scrollTo = minScroll - widgetHeight / 2 + height / 2;
|
||||
}
|
||||
|
||||
print(
|
||||
'${height * _currentIndex!}, ${MediaQuery.of(context).size.height / 2}');
|
||||
|
@ -87,12 +121,7 @@ class _LyricsScreenState extends State<LyricsScreen> {
|
|||
_animatedScroll = false;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
//Enable visualizer
|
||||
// if (settings.lyricsVisualizer) playerHelper.startVisualizer();
|
||||
_playbackStateSub = AudioService.position.listen((position) {
|
||||
void _updatePosition(Duration position) {
|
||||
if (_loading) return;
|
||||
if (!_syncedLyrics) return;
|
||||
_currentIndex =
|
||||
|
@ -105,7 +134,14 @@ class _LyricsScreenState extends State<LyricsScreen> {
|
|||
_prevIndex = _currentIndex;
|
||||
if (_freeScroll) return;
|
||||
_scrollToLyric();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
//Enable visualizer
|
||||
// if (settings.lyricsVisualizer) playerHelper.startVisualizer();
|
||||
_playbackStateSub = AudioService.position.listen(_updatePosition);
|
||||
});
|
||||
if (audioHandler.mediaItem.value != null) {
|
||||
_loadForId(audioHandler.mediaItem.value!.id);
|
||||
|
@ -130,19 +166,17 @@ class _LyricsScreenState extends State<LyricsScreen> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
ScrollBehavior get _scrollBehavior {
|
||||
if (_freeScroll) {
|
||||
return ScrollConfiguration.of(context);
|
||||
}
|
||||
|
||||
return ScrollConfiguration.of(context).copyWith(scrollbars: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PlayerScreenBackground(
|
||||
enabled: settings.playerBackgroundOnLyrics,
|
||||
appBar: AppBar(
|
||||
title: Text('Lyrics'.i18n),
|
||||
systemOverlayStyle: PlayerScreenBackground.getSystemUiOverlayStyle(
|
||||
context,
|
||||
enabled: settings.playerBackgroundOnLyrics),
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
return Column(children: [
|
||||
if (_freeScroll && !_loading)
|
||||
Center(
|
||||
child: TextButton(
|
||||
|
@ -160,9 +194,7 @@ class _LyricsScreenState extends State<LyricsScreen> {
|
|||
)),
|
||||
),
|
||||
Expanded(
|
||||
child: Stack(children: [
|
||||
//Lyrics
|
||||
_error != null
|
||||
child: _error != null
|
||||
?
|
||||
//Shouldn't really happen, empty lyrics have own text
|
||||
ErrorScreen(message: _error.toString())
|
||||
|
@ -170,9 +202,10 @@ class _LyricsScreenState extends State<LyricsScreen> {
|
|||
// Loading lyrics
|
||||
_loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: NotificationListener<ScrollStartNotification>(
|
||||
onNotification:
|
||||
(ScrollStartNotification notification) {
|
||||
: LayoutBuilder(builder: (context, constraints) {
|
||||
_widgetConstraints = constraints;
|
||||
return NotificationListener<ScrollStartNotification>(
|
||||
onNotification: (ScrollStartNotification notification) {
|
||||
if (!_syncedLyrics) return false;
|
||||
final extentDiff =
|
||||
(notification.metrics.extentBefore -
|
||||
|
@ -188,6 +221,8 @@ class _LyricsScreenState extends State<LyricsScreen> {
|
|||
}
|
||||
return false;
|
||||
},
|
||||
child: ScrollConfiguration(
|
||||
behavior: _scrollBehavior,
|
||||
child: ListView.builder(
|
||||
controller: _controller,
|
||||
itemCount: _lyrics!.lyrics!.length,
|
||||
|
@ -216,7 +251,8 @@ class _LyricsScreenState extends State<LyricsScreen> {
|
|||
child: Padding(
|
||||
padding: _currentIndex == i
|
||||
? EdgeInsets.zero
|
||||
: const EdgeInsets.symmetric(
|
||||
: const EdgeInsets
|
||||
.symmetric(
|
||||
horizontal: 1.0),
|
||||
child: 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'dart:ffi';
|
||||
|
||||
import 'package:freezer/main.dart';
|
||||
import 'package:freezer/ui/player_bar.dart';
|
||||
|
@ -132,8 +133,9 @@ class MenuSheet {
|
|||
backgroundColor: Colors.transparent,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
enableDrag: true,
|
||||
enableDrag: false,
|
||||
showDragHandle: false,
|
||||
elevation: 0.0,
|
||||
builder: (BuildContext context) {
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.5,
|
||||
|
@ -160,8 +162,12 @@ class MenuSheet {
|
|||
}
|
||||
|
||||
//Default track options
|
||||
void defaultTrackMenu(Track track,
|
||||
{List<Widget> options = const [], Function? onRemove}) {
|
||||
void defaultTrackMenu(
|
||||
Track track, {
|
||||
List<Widget> options = const [],
|
||||
Function? onRemove,
|
||||
TapDownDetails? details,
|
||||
}) {
|
||||
showWithTrack(track, [
|
||||
addToQueueNext(track),
|
||||
addToQueue(track),
|
||||
|
@ -359,7 +365,9 @@ class MenuSheet {
|
|||
|
||||
//Default album options
|
||||
void defaultAlbumMenu(Album album,
|
||||
{List<Widget> options = const [], Function? onRemove}) {
|
||||
{List<Widget> options = const [],
|
||||
Function? onRemove,
|
||||
TapDownDetails? details}) {
|
||||
show([
|
||||
album.library!
|
||||
? removeAlbum(album, onRemove: onRemove)
|
||||
|
@ -424,7 +432,9 @@ class MenuSheet {
|
|||
//===================
|
||||
|
||||
void defaultArtistMenu(Artist artist,
|
||||
{List<Widget> options = const [], Function? onRemove}) {
|
||||
{List<Widget> options = const [],
|
||||
Function? onRemove,
|
||||
TapDownDetails? details}) {
|
||||
show([
|
||||
artist.library!
|
||||
? removeArtist(artist, onRemove: onRemove)
|
||||
|
@ -467,8 +477,10 @@ class MenuSheet {
|
|||
void defaultPlaylistMenu(Playlist playlist,
|
||||
{List<Widget> options = const [],
|
||||
Function? onRemove,
|
||||
Function? onUpdate}) {
|
||||
Function? onUpdate,
|
||||
TapDownDetails? details}) {
|
||||
show([
|
||||
if (playlist.library != null)
|
||||
playlist.library!
|
||||
? removePlaylistLibrary(playlist, onRemove: onRemove)
|
||||
: addPlaylistLibrary(playlist),
|
||||
|
|
|
@ -198,7 +198,7 @@ class FancyScaffoldState extends State<FancyScaffold>
|
|||
}
|
||||
}
|
||||
|
||||
class PlayerBar extends StatefulWidget {
|
||||
class PlayerBar extends StatelessWidget {
|
||||
final VoidCallback? onTap;
|
||||
final bool shouldHaveHero;
|
||||
final Color? backgroundColor;
|
||||
|
@ -211,14 +211,7 @@ class PlayerBar extends StatefulWidget {
|
|||
this.focusNode,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<PlayerBar> createState() => _PlayerBarState();
|
||||
}
|
||||
|
||||
class _PlayerBarState extends State<PlayerBar> {
|
||||
final double iconSize = 28;
|
||||
late StreamSubscription mediaItemSub;
|
||||
late bool _isNothingPlaying = audioHandler.mediaItem.value == null;
|
||||
|
||||
double parsePosition(Duration position) {
|
||||
if (audioHandler.mediaItem.value == null) return 0.0;
|
||||
|
@ -229,59 +222,26 @@ class _PlayerBarState extends State<PlayerBar> {
|
|||
audioHandler.mediaItem.value!.duration!.inSeconds;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
mediaItemSub = audioHandler.mediaItem.listen((playingItem) {
|
||||
if ((playingItem == null && !_isNothingPlaying) ||
|
||||
(playingItem != null && _isNothingPlaying)) {
|
||||
setState(() => _isNothingPlaying = playingItem == null);
|
||||
}
|
||||
});
|
||||
super.initState();
|
||||
}
|
||||
|
||||
Color get backgroundColor =>
|
||||
widget.backgroundColor ??
|
||||
Theme.of(context).navigationBarTheme.backgroundColor ??
|
||||
Theme.of(context).colorScheme.surface;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
mediaItemSub.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool _gestureRegistered = false;
|
||||
Color? get _backgroundColor => backgroundColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 68.0,
|
||||
child: _isNothingPlaying
|
||||
? null
|
||||
: GestureDetector(
|
||||
onHorizontalDragUpdate: (details) async {
|
||||
if (_gestureRegistered) return;
|
||||
const double sensitivity = 12.69;
|
||||
//Right swipe
|
||||
_gestureRegistered = true;
|
||||
if (details.delta.dx > sensitivity) {
|
||||
await audioHandler.skipToPrevious();
|
||||
}
|
||||
//Left
|
||||
if (details.delta.dx < -sensitivity) {
|
||||
await audioHandler.skipToNext();
|
||||
}
|
||||
_gestureRegistered = false;
|
||||
return;
|
||||
},
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
Expanded(
|
||||
child: StreamBuilder<MediaItem?>(
|
||||
stream: audioHandler.mediaItem,
|
||||
initialData: audioHandler.mediaItem.valueOrNull,
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) return const SizedBox();
|
||||
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 image = CachedImage(
|
||||
width: 50,
|
||||
|
@ -289,16 +249,16 @@ class _PlayerBarState extends State<PlayerBar> {
|
|||
url: currentMediaItem.extras!['thumb'] ??
|
||||
currentMediaItem.artUri.toString(),
|
||||
);
|
||||
final leadingWidget = widget.shouldHaveHero
|
||||
final leadingWidget = shouldHaveHero
|
||||
? Hero(tag: currentMediaItem.id, child: image)
|
||||
: image;
|
||||
return Material(
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
focusNode: widget.focusNode,
|
||||
tileColor: _backgroundColor,
|
||||
focusNode: focusNode,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
onTap: widget.onTap,
|
||||
onTap: onTap,
|
||||
leading: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: leadingWidget),
|
||||
|
@ -342,7 +302,6 @@ class _PlayerBarState extends State<PlayerBar> {
|
|||
}),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:ui';
|
||||
import 'dart:async';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
@ -9,6 +10,7 @@ import 'package:freezer/api/cache.dart';
|
|||
import 'package:freezer/api/deezer.dart';
|
||||
import 'package:freezer/api/definitions.dart';
|
||||
import 'package:freezer/api/player.dart';
|
||||
import 'package:freezer/main.dart';
|
||||
import 'package:freezer/page_routes/fade.dart';
|
||||
import 'package:freezer/settings.dart';
|
||||
import 'package:freezer/translations.i18n.dart';
|
||||
|
@ -91,7 +93,9 @@ class PlayerScreen extends StatelessWidget {
|
|||
return ChangeNotifierProvider(
|
||||
create: (context) => BackgroundProvider(),
|
||||
child: PlayerScreenBackground(
|
||||
child: OrientationBuilder(
|
||||
child: MainScreen.of(context).isDesktop
|
||||
? const PlayerScreenDesktop()
|
||||
: OrientationBuilder(
|
||||
builder: (context, orientation) =>
|
||||
orientation == Orientation.landscape
|
||||
? const PlayerScreenHorizontal()
|
||||
|
@ -287,8 +291,11 @@ class PlayerScreenVertical extends StatelessWidget {
|
|||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||
child: PlayerTextSubtext(textSize: 64.sp),
|
||||
),
|
||||
SeekBar(textSize: 48.sp),
|
||||
PlaybackControls(86.sp),
|
||||
SeekBar(textSize: 38.sp),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: PlaybackControls(86.sp),
|
||||
),
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 0, horizontal: 16.0),
|
||||
|
@ -299,6 +306,98 @@ class PlayerScreenVertical extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
class PlayerScreenDesktop extends StatelessWidget {
|
||||
const PlayerScreenDesktop({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(children: [
|
||||
Flexible(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: PlayerScreenTopRow(
|
||||
textSize: 10.sp,
|
||||
iconSize: 17.sp,
|
||||
showQueueButton: false,
|
||||
),
|
||||
),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints.loose(const Size.square(500)),
|
||||
child: const BigAlbumArt()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||
child: PlayerTextSubtext(textSize: 18.sp),
|
||||
),
|
||||
SeekBar(textSize: 12.sp),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: PlaybackControls(24.sp),
|
||||
),
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 0, horizontal: 16.0),
|
||||
child: BottomBarControls(
|
||||
size: 16.sp,
|
||||
showLyricsButton: false,
|
||||
),
|
||||
)
|
||||
]),
|
||||
),
|
||||
),
|
||||
const Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: 16.0, right: 16.0, top: 24.0),
|
||||
child: _DesktopTabView(),
|
||||
)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _DesktopTabView extends StatelessWidget {
|
||||
const _DesktopTabView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Column(children: [
|
||||
TabBar(
|
||||
tabs: [
|
||||
Tab(
|
||||
text: 'Queue'.i18n,
|
||||
height: 48.0,
|
||||
),
|
||||
Tab(
|
||||
text: 'Lyrics'.i18n,
|
||||
),
|
||||
],
|
||||
labelStyle: Theme.of(context)
|
||||
.textTheme
|
||||
.labelLarge!
|
||||
.copyWith(fontSize: 18.0)),
|
||||
const Expanded(
|
||||
child: SizedBox.expand(
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: TabBarView(children: [
|
||||
!kDebugMode
|
||||
? Text('Queue view is disabled in Debug mode')
|
||||
: QueueListWidget(),
|
||||
LyricsWidget(),
|
||||
]),
|
||||
),
|
||||
)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FitOrScrollText extends StatefulWidget {
|
||||
final String text;
|
||||
final TextStyle style;
|
||||
|
@ -741,23 +840,32 @@ class _BigAlbumArtState extends State<BigAlbumArt> {
|
|||
// },
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
PageRouteBuilder(
|
||||
opaque: false, // transparent background
|
||||
FadePageRoute(
|
||||
barrierDismissible: true,
|
||||
pageBuilder: (context, animation, __) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: PhotoView(
|
||||
imageProvider: CachedNetworkImageProvider(
|
||||
audioHandler.mediaItem.value!.artUri.toString()),
|
||||
maxScale: 8.0,
|
||||
minScale: 0.2,
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: audioHandler.mediaItem.value!.id),
|
||||
backgroundDecoration: const BoxDecoration(
|
||||
color: Color.fromARGB(0x90, 0, 0, 0))),
|
||||
);
|
||||
})),
|
||||
builder: (context) {
|
||||
final mediaItem = audioHandler.mediaItem.value!;
|
||||
return ZoomableImageRoute(
|
||||
imageUrl: mediaItem.artUri.toString(), heroKey: mediaItem.id);
|
||||
},
|
||||
)
|
||||
// PageRouteBuilder(
|
||||
// opaque: false, // transparent background
|
||||
// barrierDismissible: true,
|
||||
// pageBuilder: (context, animation, __) {
|
||||
// return FadeTransition(
|
||||
// opacity: animation,
|
||||
// child: PhotoView(
|
||||
// imageProvider: CachedNetworkImageProvider(
|
||||
// audioHandler.mediaItem.value!.artUri.toString()),
|
||||
// maxScale: 8.0,
|
||||
// minScale: 0.2,
|
||||
// heroAttributes: PhotoViewHeroAttributes(
|
||||
// tag: audioHandler.mediaItem.value!.id),
|
||||
// backgroundDecoration: const BoxDecoration(
|
||||
// color: Color.fromARGB(0x90, 0, 0, 0))),
|
||||
// );
|
||||
// }),
|
||||
),
|
||||
onHorizontalDragDown: (_) => _userScroll = true,
|
||||
// delayed a bit, so to make sure that the page view updated.
|
||||
onHorizontalDragEnd: (_) => Future.delayed(
|
||||
|
@ -826,12 +934,14 @@ class PlayerScreenTopRow extends StatelessWidget {
|
|||
final double? iconSize;
|
||||
final double? textWidth;
|
||||
final bool short;
|
||||
final bool showQueueButton; // not needed on desktop
|
||||
const PlayerScreenTopRow(
|
||||
{super.key,
|
||||
this.textSize,
|
||||
this.iconSize,
|
||||
this.textWidth,
|
||||
this.short = false});
|
||||
this.short = false,
|
||||
this.showQueueButton = true});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@ -867,7 +977,8 @@ class PlayerScreenTopRow extends StatelessWidget {
|
|||
TextSpan(text: playerHelper.queueSource!.text ?? '')
|
||||
], style: TextStyle(fontSize: textSize ?? 38.sp))),
|
||||
),
|
||||
IconButton(
|
||||
showQueueButton
|
||||
? IconButton(
|
||||
icon: Icon(
|
||||
Icons.menu,
|
||||
semanticLabel: "Queue".i18n,
|
||||
|
@ -876,7 +987,8 @@ class PlayerScreenTopRow extends StatelessWidget {
|
|||
splashRadius: size * 1.5,
|
||||
onPressed: () => Navigator.of(context)
|
||||
.pushRoute(builder: (context) => const QueueScreen()),
|
||||
),
|
||||
)
|
||||
: SizedBox.square(dimension: size + 16.0),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@ -997,7 +1109,13 @@ class _SeekBarState extends State<SeekBar> {
|
|||
|
||||
class BottomBarControls extends StatelessWidget {
|
||||
final double size;
|
||||
const BottomBarControls({Key? key, required this.size}) : super(key: key);
|
||||
final bool
|
||||
showLyricsButton; // removed in desktop mode, because there's a tabbed view which includes it
|
||||
const BottomBarControls({
|
||||
super.key,
|
||||
required this.size,
|
||||
this.showLyricsButton = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@ -1018,6 +1136,8 @@ class BottomBarControls extends StatelessWidget {
|
|||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
QualityInfoWidget(textSize: size * 0.75),
|
||||
if (showLyricsButton)
|
||||
IconButton(
|
||||
iconSize: size,
|
||||
icon: Icon(
|
||||
|
@ -1055,7 +1175,6 @@ class BottomBarControls extends StatelessWidget {
|
|||
// toastLength: Toast.LENGTH_SHORT);
|
||||
// },
|
||||
// ),
|
||||
QualityInfoWidget(textSize: size * 0.75),
|
||||
FavoriteButton(size: size * 0.85),
|
||||
PlayerMenuButton(size: size)
|
||||
],
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
@ -8,15 +9,50 @@ import 'package:freezer/translations.i18n.dart';
|
|||
import 'package:freezer/ui/menu.dart';
|
||||
import 'package:freezer/ui/tiles.dart';
|
||||
|
||||
class QueueScreen extends StatefulWidget {
|
||||
class QueueScreen extends StatelessWidget {
|
||||
const QueueScreen({super.key});
|
||||
|
||||
@override
|
||||
State<QueueScreen> createState() => _QueueScreenState();
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Queue'.i18n),
|
||||
systemOverlayStyle: SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
statusBarBrightness: Brightness.light,
|
||||
systemNavigationBarColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
systemNavigationBarDividerColor: Color(
|
||||
Theme.of(context).scaffoldBackgroundColor.value - 0x00111111),
|
||||
systemNavigationBarIconBrightness: Brightness.light,
|
||||
),
|
||||
// actions: <Widget>[
|
||||
// IconButton(
|
||||
// icon: Icon(
|
||||
// Icons.shuffle,
|
||||
// semanticLabel: "Shuffle".i18n,
|
||||
// ),
|
||||
// onPressed: () async {
|
||||
// await playerHelper.toggleShuffle();
|
||||
// setState(() {});
|
||||
// },
|
||||
// )
|
||||
// ],
|
||||
),
|
||||
body: const SafeArea(child: QueueListWidget(shouldPopOnTap: true)));
|
||||
}
|
||||
}
|
||||
|
||||
class _QueueScreenState extends State<QueueScreen> {
|
||||
static const itemExtent = 72.0; // height of each TrackTile
|
||||
class QueueListWidget extends StatefulWidget {
|
||||
final bool shouldPopOnTap;
|
||||
const QueueListWidget({super.key, this.shouldPopOnTap = false});
|
||||
|
||||
@override
|
||||
State<QueueListWidget> createState() => _QueueListWidgetState();
|
||||
}
|
||||
|
||||
class _QueueListWidgetState extends State<QueueListWidget> {
|
||||
static const itemExtent = 68.0; // height of each TrackTile
|
||||
final _scrollController = ScrollController();
|
||||
late StreamSubscription _queueSub;
|
||||
static const _dismissibleBackground = DecoratedBox(
|
||||
|
@ -55,7 +91,9 @@ class _QueueScreenState extends State<QueueScreen> {
|
|||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// calculate position of current item
|
||||
final position = playerHelper.queueIndex * itemExtent;
|
||||
double position = min(playerHelper.queueIndex * itemExtent,
|
||||
_scrollController.position.maxScrollExtent);
|
||||
|
||||
_scrollController.jumpTo(position);
|
||||
});
|
||||
super.initState();
|
||||
|
@ -69,33 +107,7 @@ class _QueueScreenState extends State<QueueScreen> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Queue'.i18n),
|
||||
systemOverlayStyle: SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
statusBarBrightness: Brightness.light,
|
||||
systemNavigationBarColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
systemNavigationBarDividerColor: Color(
|
||||
Theme.of(context).scaffoldBackgroundColor.value - 0x00111111),
|
||||
systemNavigationBarIconBrightness: Brightness.light,
|
||||
),
|
||||
// actions: <Widget>[
|
||||
// IconButton(
|
||||
// icon: Icon(
|
||||
// Icons.shuffle,
|
||||
// semanticLabel: "Shuffle".i18n,
|
||||
// ),
|
||||
// onPressed: () async {
|
||||
// await playerHelper.toggleShuffle();
|
||||
// setState(() {});
|
||||
// },
|
||||
// )
|
||||
// ],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: ReorderableListView.builder(
|
||||
return ReorderableListView.builder(
|
||||
buildDefaultDragHandles: false,
|
||||
scrollController: _scrollController,
|
||||
// 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,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
Track track = Track.fromMediaItem(_queueCache[index]);
|
||||
final int itemId = _queueCache[index].extras!['id'] ?? 0;
|
||||
final mediaItem = _queueCache[index];
|
||||
final int itemId = mediaItem.extras!['id'] ?? 0;
|
||||
return Dismissible(
|
||||
key: ValueKey(track.id.hashCode ^ itemId),
|
||||
key: ValueKey(mediaItem.id.hashCode | itemId),
|
||||
background: _dismissibleBackground,
|
||||
secondaryBackground: _dismissibleSecondaryBackground,
|
||||
onDismissed: (_) {
|
||||
|
@ -145,22 +157,24 @@ class _QueueScreenState extends State<QueueScreen> {
|
|||
},
|
||||
child: SizedBox(
|
||||
height: itemExtent,
|
||||
child: TrackTile(
|
||||
track,
|
||||
child: TrackTile.fromMediaItem(
|
||||
mediaItem,
|
||||
trailing: ReorderableDragStartListener(
|
||||
index: index, child: const Icon(Icons.drag_handle)),
|
||||
onTap: () {
|
||||
audioHandler.skipToQueueItem(index).then((value) {
|
||||
if (widget.shouldPopOnTap) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
});
|
||||
},
|
||||
onHold: () => MenuSheet(context).defaultTrackMenu(track),
|
||||
onSecondary: (_) => MenuSheet(context)
|
||||
.defaultTrackMenu(Track.fromMediaItem(mediaItem)),
|
||||
checkTrackOffline: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -277,7 +277,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
final data = cache.searchHistory[i].data;
|
||||
switch (cache.searchHistory[i].type) {
|
||||
case SearchHistoryItemType.track:
|
||||
return TrackTile(
|
||||
return TrackTile.fromTrack(
|
||||
data,
|
||||
onTap: () {
|
||||
List<Track?> queue = cache.searchHistory
|
||||
|
@ -293,8 +293,8 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
source: 'searchhistory',
|
||||
id: 'searchhistory'));
|
||||
},
|
||||
onHold: () =>
|
||||
MenuSheet(context).defaultTrackMenu(data),
|
||||
onSecondary: (details) => MenuSheet(context)
|
||||
.defaultTrackMenu(data, details: details),
|
||||
trailing: _removeHistoryItemWidget(i),
|
||||
);
|
||||
case SearchHistoryItemType.album:
|
||||
|
@ -304,8 +304,8 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
Navigator.of(context).pushRoute(
|
||||
builder: (context) => AlbumDetails(data));
|
||||
},
|
||||
onHold: () =>
|
||||
MenuSheet(context).defaultAlbumMenu(data),
|
||||
onSecondary: (details) => MenuSheet(context)
|
||||
.defaultAlbumMenu(data, details: details),
|
||||
trailing: _removeHistoryItemWidget(i),
|
||||
);
|
||||
case SearchHistoryItemType.artist:
|
||||
|
@ -328,8 +328,9 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
builder: (context) =>
|
||||
PlaylistDetails(data));
|
||||
},
|
||||
onHold: () => MenuSheet(context)
|
||||
.defaultPlaylistMenu(data),
|
||||
onSecondary: (details) => MenuSheet(context)
|
||||
.defaultPlaylistMenu(data,
|
||||
details: details),
|
||||
trailing: _removeHistoryItemWidget(i),
|
||||
);
|
||||
default:
|
||||
|
@ -477,7 +478,7 @@ class SearchResultsScreen extends StatelessWidget {
|
|||
),
|
||||
for (final track in results.tracks!
|
||||
.getRange(0, min(results.tracks!.length, 3)))
|
||||
TrackTile(track, onTap: () {
|
||||
TrackTile.fromTrack(track, onTap: () {
|
||||
cache.addToSearchHistory(track);
|
||||
playerHelper.playFromTrackList(
|
||||
results.tracks!,
|
||||
|
@ -486,9 +487,9 @@ class SearchResultsScreen extends StatelessWidget {
|
|||
text: 'Search'.i18n,
|
||||
id: query,
|
||||
source: 'search'));
|
||||
}, onHold: () {
|
||||
}, onSecondary: (details) {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultTrackMenu(track);
|
||||
m.defaultTrackMenu(track, details: details);
|
||||
}),
|
||||
ListTile(
|
||||
title: Text('Show all tracks'.i18n),
|
||||
|
@ -518,9 +519,9 @@ class SearchResultsScreen extends StatelessWidget {
|
|||
),
|
||||
for (final album in results.albums!
|
||||
.getRange(0, min(results.albums!.length, 3)))
|
||||
AlbumTile(album, onHold: () {
|
||||
AlbumTile(album, onSecondary: (details) {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultAlbumMenu(album);
|
||||
m.defaultAlbumMenu(album, details: details);
|
||||
}, onTap: () {
|
||||
cache.addToSearchHistory(album);
|
||||
Navigator.of(context)
|
||||
|
@ -560,9 +561,9 @@ class SearchResultsScreen extends StatelessWidget {
|
|||
Navigator.of(context).pushRoute(
|
||||
builder: (context) => ArtistDetails(artist));
|
||||
},
|
||||
onHold: () {
|
||||
onSecondary: (details) {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultArtistMenu(artist);
|
||||
m.defaultArtistMenu(artist, details: details);
|
||||
},
|
||||
),
|
||||
])),
|
||||
|
@ -590,9 +591,9 @@ class SearchResultsScreen extends StatelessWidget {
|
|||
Navigator.of(context).pushRoute(
|
||||
builder: (context) => PlaylistDetails(playlist));
|
||||
},
|
||||
onHold: () {
|
||||
onSecondary: (details) {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultPlaylistMenu(playlist);
|
||||
m.defaultPlaylistMenu(playlist, details: details);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
|
@ -702,14 +703,14 @@ class TrackListScreen extends StatelessWidget {
|
|||
itemCount: tracks!.length,
|
||||
itemBuilder: (BuildContext context, int i) {
|
||||
Track t = tracks![i]!;
|
||||
return TrackTile(
|
||||
return TrackTile.fromTrack(
|
||||
t,
|
||||
onTap: () {
|
||||
playerHelper.playFromTrackList(tracks!, t.id, queueSource);
|
||||
},
|
||||
onHold: () {
|
||||
onSecondary: (details) {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultTrackMenu(t);
|
||||
m.defaultTrackMenu(t, details: details);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
@ -737,9 +738,9 @@ class AlbumListScreen extends StatelessWidget {
|
|||
Navigator.of(context)
|
||||
.pushRoute(builder: (context) => AlbumDetails(a));
|
||||
},
|
||||
onHold: () {
|
||||
onSecondary: (details) {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultAlbumMenu(a!);
|
||||
m.defaultAlbumMenu(a!, details: details);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
@ -766,9 +767,9 @@ class SearchResultPlaylists extends StatelessWidget {
|
|||
Navigator.of(context)
|
||||
.pushRoute(builder: (context) => PlaylistDetails(p));
|
||||
},
|
||||
onHold: () {
|
||||
onSecondary: (details) {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultPlaylistMenu(p!);
|
||||
m.defaultPlaylistMenu(p!, details: details);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:country_pickers/country.dart';
|
||||
import 'package:country_pickers/country_picker_dialog.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_displaymode/flutter_displaymode.dart';
|
||||
|
@ -144,6 +147,18 @@ class AppearanceSettings extends StatefulWidget {
|
|||
class _AppearanceSettingsState extends State<AppearanceSettings> {
|
||||
ColorSwatch<dynamic> _swatch(int c) => ColorSwatch(c, {500: Color(c)});
|
||||
|
||||
String _navigationRailAppearanceToString(
|
||||
NavigationRailAppearance navigationRailAppearance) {
|
||||
switch (navigationRailAppearance) {
|
||||
case NavigationRailAppearance.always_expanded:
|
||||
return 'Always expanded'.i18n;
|
||||
case NavigationRailAppearance.expand_on_hover:
|
||||
return 'Expand on hover'.i18n;
|
||||
case NavigationRailAppearance.icons_only:
|
||||
return 'Icons only'.i18n;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
|
@ -411,7 +426,30 @@ class _AppearanceSettingsState extends State<AppearanceSettings> {
|
|||
value: settings.useArtColor,
|
||||
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(
|
||||
leading: const Icon(Icons.screen_lock_portrait),
|
||||
title: Text('Change display mode'.i18n),
|
||||
|
|
|
@ -12,31 +12,107 @@ import 'cached_image.dart';
|
|||
|
||||
import 'dart:async';
|
||||
|
||||
class TrackTile extends StatelessWidget {
|
||||
final Track track;
|
||||
final void Function()? onTap;
|
||||
final void Function()? onHold;
|
||||
final Widget? trailing;
|
||||
typedef SecondaryTapCallback = void Function(TapDownDetails?);
|
||||
|
||||
const TrackTile(this.track,
|
||||
{this.onTap, this.onHold, this.trailing, Key? key})
|
||||
: super(key: key);
|
||||
class WrapSecondaryAction extends StatelessWidget {
|
||||
final SecondaryTapCallback? onSecondaryTapDown;
|
||||
final Widget child;
|
||||
const WrapSecondaryAction(
|
||||
{super.key, this.onSecondaryTapDown, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
return GestureDetector(
|
||||
onSecondaryTapDown: onSecondaryTapDown,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
VoidCallback? normalizeSecondary(SecondaryTapCallback? callback) {
|
||||
if (callback == null) return null;
|
||||
|
||||
return () => callback.call(null);
|
||||
}
|
||||
|
||||
class TrackTile extends StatelessWidget {
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Hold or Right Click
|
||||
final SecondaryTapCallback? onSecondary;
|
||||
final Widget? trailing;
|
||||
final String trackId;
|
||||
final String title;
|
||||
final String artist;
|
||||
final String artUri;
|
||||
final bool explicit;
|
||||
final String durationString;
|
||||
|
||||
/// Disable if not needed, makes app lag, and uses lots of resources
|
||||
final bool checkTrackOffline;
|
||||
|
||||
const TrackTile({
|
||||
required this.trackId,
|
||||
required this.title,
|
||||
required this.artist,
|
||||
required this.artUri,
|
||||
required this.explicit,
|
||||
required this.durationString,
|
||||
this.onTap,
|
||||
this.onSecondary,
|
||||
this.trailing,
|
||||
this.checkTrackOffline = true,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
factory TrackTile.fromTrack(Track track,
|
||||
{VoidCallback? onTap,
|
||||
SecondaryTapCallback? onSecondary,
|
||||
Widget? trailing,
|
||||
bool checkTrackOffline = true}) =>
|
||||
TrackTile(
|
||||
trackId: track.id,
|
||||
title: track.title!,
|
||||
artist: track.artistString,
|
||||
artUri: track.albumArt!.thumb,
|
||||
explicit: track.explicit!,
|
||||
durationString: track.durationString,
|
||||
onSecondary: onSecondary,
|
||||
onTap: onTap,
|
||||
trailing: trailing,
|
||||
checkTrackOffline: checkTrackOffline,
|
||||
);
|
||||
|
||||
factory TrackTile.fromMediaItem(MediaItem mediaItem,
|
||||
{VoidCallback? onTap,
|
||||
SecondaryTapCallback? onSecondary,
|
||||
Widget? trailing,
|
||||
bool checkTrackOffline = true}) =>
|
||||
TrackTile(
|
||||
trackId: mediaItem.id,
|
||||
title: mediaItem.title,
|
||||
artist: mediaItem.artist!,
|
||||
artUri: mediaItem.extras!['thumb'],
|
||||
explicit: false,
|
||||
durationString: Track.durationAsString(mediaItem.duration!),
|
||||
onSecondary: onSecondary,
|
||||
onTap: onTap,
|
||||
trailing: trailing,
|
||||
checkTrackOffline: checkTrackOffline,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return WrapSecondaryAction(
|
||||
onSecondaryTapDown: onSecondary,
|
||||
child: ListTile(
|
||||
title: StreamBuilder<MediaItem?>(
|
||||
stream: audioHandler.mediaItem,
|
||||
builder: (context, snapshot) {
|
||||
final bool isHighlighted;
|
||||
final mediaItem = snapshot.data;
|
||||
if (!snapshot.hasData || snapshot.data == null) {
|
||||
isHighlighted = false;
|
||||
} else {
|
||||
isHighlighted = mediaItem!.id == track.id;
|
||||
}
|
||||
final bool isHighlighted = mediaItem?.id == trackId;
|
||||
return Text(
|
||||
track.title!,
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
style: TextStyle(
|
||||
|
@ -46,21 +122,23 @@ class TrackTile extends StatelessWidget {
|
|||
);
|
||||
}),
|
||||
subtitle: Text(
|
||||
track.artistString,
|
||||
artist,
|
||||
maxLines: 1,
|
||||
),
|
||||
leading: CachedImage(
|
||||
url: track.albumArt!.thumb,
|
||||
url: artUri,
|
||||
width: 48.0,
|
||||
height: 48.0,
|
||||
),
|
||||
onTap: onTap,
|
||||
onLongPress: onHold,
|
||||
onLongPress: normalizeSecondary(onSecondary),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (checkTrackOffline)
|
||||
FutureBuilder<bool>(
|
||||
future: downloadManager.checkOffline(track: track),
|
||||
future:
|
||||
downloadManager.checkOffline(track: Track(id: trackId)),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.data == true) {
|
||||
return const Padding(
|
||||
|
@ -74,7 +152,7 @@ class TrackTile extends StatelessWidget {
|
|||
}
|
||||
return const SizedBox.shrink();
|
||||
}),
|
||||
if (track.explicit ?? false)
|
||||
if (explicit)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 2.0),
|
||||
child: Text(
|
||||
|
@ -85,13 +163,14 @@ class TrackTile extends StatelessWidget {
|
|||
SizedBox(
|
||||
width: 42.0,
|
||||
child: Text(
|
||||
track.durationString,
|
||||
durationString,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
if (trailing != null) trailing!
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -99,15 +178,19 @@ class TrackTile extends StatelessWidget {
|
|||
class AlbumTile extends StatelessWidget {
|
||||
final Album? album;
|
||||
final void Function()? onTap;
|
||||
final void Function()? onHold;
|
||||
|
||||
/// Hold or Right click
|
||||
final SecondaryTapCallback? onSecondary;
|
||||
final Widget? trailing;
|
||||
|
||||
const AlbumTile(this.album,
|
||||
{super.key, this.onTap, this.onHold, this.trailing});
|
||||
{super.key, this.onTap, this.onSecondary, this.trailing});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
return WrapSecondaryAction(
|
||||
onSecondaryTapDown: onSecondary,
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
album!.title!,
|
||||
maxLines: 1,
|
||||
|
@ -121,8 +204,9 @@ class AlbumTile extends StatelessWidget {
|
|||
width: 48,
|
||||
),
|
||||
onTap: onTap,
|
||||
onLongPress: onHold,
|
||||
onLongPress: normalizeSecondary(onSecondary),
|
||||
trailing: trailing,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -130,16 +214,19 @@ class AlbumTile extends StatelessWidget {
|
|||
class ArtistTile extends StatelessWidget {
|
||||
final Artist? artist;
|
||||
final void Function()? onTap;
|
||||
final void Function()? onHold;
|
||||
|
||||
const ArtistTile(this.artist, {super.key, this.onTap, this.onHold});
|
||||
/// Hold or Right click
|
||||
final SecondaryTapCallback? onSecondary;
|
||||
|
||||
const ArtistTile(this.artist, {super.key, this.onTap, this.onSecondary});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
|
||||
onTap: onTap,
|
||||
onLongPress: onHold,
|
||||
onLongPress: normalizeSecondary(onSecondary),
|
||||
onSecondaryTapDown: onSecondary,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
const SizedBox(height: 4),
|
||||
CachedImage(
|
||||
|
@ -163,11 +250,11 @@ class ArtistTile extends StatelessWidget {
|
|||
class PlaylistTile extends StatelessWidget {
|
||||
final Playlist? playlist;
|
||||
final void Function()? onTap;
|
||||
final void Function()? onHold;
|
||||
final SecondaryTapCallback? onSecondary;
|
||||
final Widget? trailing;
|
||||
|
||||
const PlaylistTile(this.playlist,
|
||||
{super.key, this.onHold, this.onTap, this.trailing});
|
||||
{super.key, this.onSecondary, this.onTap, this.trailing});
|
||||
|
||||
String? get subtitle {
|
||||
if (playlist!.user == null ||
|
||||
|
@ -182,7 +269,9 @@ class PlaylistTile extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
return WrapSecondaryAction(
|
||||
onSecondaryTapDown: onSecondary,
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
playlist!.title!,
|
||||
maxLines: 1,
|
||||
|
@ -196,8 +285,9 @@ class PlaylistTile extends StatelessWidget {
|
|||
width: 48,
|
||||
),
|
||||
onTap: onTap,
|
||||
onLongPress: onHold,
|
||||
onLongPress: normalizeSecondary(onSecondary),
|
||||
trailing: trailing,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -57,6 +57,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.18.12"
|
||||
audio_service_mpris:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: audio_service_mpris
|
||||
sha256: "31be5de2db0c71b217157afce1974ac6d0ad329bd91deb1f19ad094d29340d8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
audio_service_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
@ -93,6 +93,7 @@ dependencies:
|
|||
isar: ^3.1.0+1
|
||||
isar_flutter_libs: ^3.1.0+1
|
||||
flutter_background_service: ^5.0.1
|
||||
audio_service_mpris: ^0.1.0
|
||||
#deezcryptor:
|
||||
#path: deezcryptor/
|
||||
|
||||
|
|
Loading…
Reference in a new issue