diff --git a/android/app/build.gradle b/android/app/build.gradle index 5f55776..c78206d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -29,19 +29,30 @@ if (keystorePropertiesFile.exists()) { } android { - compileSdkVersion 34 + namespace "com.unicornsonlsd.finamp" + compileSdkVersion 36 + ndkVersion "28.2.13676358" sourceSets { main.java.srcDirs += 'src/main/kotlin' } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + lintOptions { disable 'InvalidPackage' } defaultConfig { applicationId "com.unicornsonlsd.finamp" - minSdkVersion 21 + minSdkVersion flutter.minSdkVersion targetSdkVersion 34 versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/android/build.gradle b/android/build.gradle index 65722a4..73cbc5f 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -14,8 +14,8 @@ subprojects { if (project.plugins.hasPlugin("com.android.application") || project.plugins.hasPlugin("com.android.library")) { project.android { - compileSdkVersion 34 - buildToolsVersion "34.0.0" + compileSdkVersion 36 + buildToolsVersion "36.1.0" } } } diff --git a/android/gradle.properties b/android/gradle.properties index 38c8d45..6f80cfa 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,8 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4096M -XX:MaxMetaspaceSize=1024M android.enableR8=true android.useAndroidX=true android.enableJetifier=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index dcf0f19..74b269f 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index 229de2e..e3b8760 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -19,8 +19,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "7.4.0" apply false - id "org.jetbrains.kotlin.android" version "1.9.10" apply false + id "com.android.application" version "8.11.1" apply false + id "org.jetbrains.kotlin.android" version "2.0.20" apply false } include ":app" diff --git a/lib/color_schemes.g.dart b/lib/color_schemes.g.dart index d166415..19e89b4 100644 --- a/lib/color_schemes.g.dart +++ b/lib/color_schemes.g.dart @@ -46,39 +46,39 @@ const lightColorScheme = ColorScheme( const darkColorScheme = ColorScheme( brightness: Brightness.dark, // Primary - primary: jellyfinBlueColor, - onPrimary: Color(0xFF001E2C), - primaryContainer: Color(0xFF004C68), - onPrimaryContainer: Color(0xFFC3E7FF), + primary: Color(0xFF25D4FF), + onPrimary: Color(0xFF02111F), + primaryContainer: Color(0xFF0B4268), + onPrimaryContainer: Color(0xFFD6F3FF), // Secondary - secondary: Color(0xFF60B4DD), - onSecondary: Color(0xFF112732), - secondaryContainer: Color(0xFF206B8C), - onSecondaryContainer: Color(0xFFCEEEFF), + secondary: Color(0xFF7E8CFF), + onSecondary: Color(0xFF080B2E), + secondaryContainer: Color(0xFF262B72), + onSecondaryContainer: Color(0xFFE0E3FF), // Tertiary - tertiary: Color(0xFFC979E2), - onTertiary: Color(0xFF3D0050), - tertiaryContainer: Color(0xFF762A90), - onTertiaryContainer: Color(0xFFFAD7FF), + tertiary: Color(0xFFFF5FC8), + onTertiary: Color(0xFF370027), + tertiaryContainer: Color(0xFF6B0B55), + onTertiaryContainer: Color(0xFFFFD7F0), // Error error: Color(0xFFFFB4AB), errorContainer: Color(0xFF93000A), onError: Color(0xFF690005), onErrorContainer: Color(0xFFFFDAD6), // Background & Surface - background: Color(0xFF101315), - onBackground: Color(0xFFE1E2E5), - surface: Color(0xFF101315), - onSurface: Color(0xFFE1E2E5), - surfaceVariant: Color(0xFF333A3E), - onSurfaceVariant: Color(0xFFC0C7CD), + background: Color(0xFF030916), + onBackground: Color(0xFFE8F2FF), + surface: Color(0xFF061426), + onSurface: Color(0xFFE8F2FF), + surfaceVariant: Color(0xFF10233D), + onSurfaceVariant: Color(0xFFA8B8D2), // Other colors - outline: Color(0xFF80878C), + outline: Color(0xFF61708D), onInverseSurface: Color(0xFF191C1E), inverseSurface: Color(0xFFE1E2E5), inversePrimary: Color(0xFF00668A), shadow: Color(0xFF000000), - surfaceTint: Color(0xFF7BD0FF), - outlineVariant: Color(0xFF41484D), + surfaceTint: Color(0xFF25D4FF), + outlineVariant: Color(0xFF233753), scrim: Color(0xFF000000), -); \ No newline at end of file +); diff --git a/lib/components/DownloadsScreen/current_downloads_list.dart b/lib/components/DownloadsScreen/current_downloads_list.dart index 77290a0..476b613 100644 --- a/lib/components/DownloadsScreen/current_downloads_list.dart +++ b/lib/components/DownloadsScreen/current_downloads_list.dart @@ -7,6 +7,7 @@ import 'package:flutter_downloader/flutter_downloader.dart'; import 'package:get_it/get_it.dart'; import '../../models/finamp_models.dart'; +import '../../services/download_support.dart'; import '../error_snackbar.dart'; import '../album_image.dart'; @@ -25,6 +26,10 @@ class _CurrentDownloadsListState extends State { void initState() { super.initState(); + if (!isDownloaderSupported) { + return; + } + IsolateNameServer.registerPortWithName( _port.sendPort, 'downloader_send_port'); _port.listen((dynamic data) { @@ -36,7 +41,9 @@ class _CurrentDownloadsListState extends State { @override void dispose() { - IsolateNameServer.removePortNameMapping('downloader_send_port'); + if (isDownloaderSupported) { + IsolateNameServer.removePortNameMapping('downloader_send_port'); + } super.dispose(); } diff --git a/lib/components/DownloadsScreen/downloads_overview.dart b/lib/components/DownloadsScreen/downloads_overview.dart index f2adc2c..7803c81 100644 --- a/lib/components/DownloadsScreen/downloads_overview.dart +++ b/lib/components/DownloadsScreen/downloads_overview.dart @@ -6,6 +6,7 @@ import 'package:finamp/l10n/app_localizations.dart'; import 'package:get_it/get_it.dart'; import '../../services/download_update_stream.dart'; +import '../../services/download_support.dart'; import '../error_snackbar.dart'; const double downloadsOverviewCardLoadingHeight = 120; @@ -39,7 +40,9 @@ class _DownloadsOverviewState extends State { @override void initState() { super.initState(); - _downloadsOverviewFuture = FlutterDownloader.loadTasks(); + _downloadsOverviewFuture = isDownloaderSupported + ? FlutterDownloader.loadTasks() + : Future.value([]); // Like in DownloadedIndicator, we use our own listener instead of a // StreamBuilder to ensure that we capture all events. diff --git a/lib/components/MusicScreen/album_item_card.dart b/lib/components/MusicScreen/album_item_card.dart index 985696f..3932f87 100644 --- a/lib/components/MusicScreen/album_item_card.dart +++ b/lib/components/MusicScreen/album_item_card.dart @@ -25,14 +25,34 @@ class AlbumItemCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Card( - // In AlbumItem, the OpenContainer handles padding. - margin: EdgeInsets.zero, + final colorScheme = Theme.of(context).colorScheme; + + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(26), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withOpacity(0.32), + blurRadius: 22, + offset: const Offset(0, 12), + ), + ], + ), child: ClipRRect( - borderRadius: AlbumImage.borderRadius, + borderRadius: BorderRadius.circular(26), child: Stack( children: [ - AlbumImage(item: item), + Positioned.fill(child: AlbumImage(item: item)), + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: Colors.white.withOpacity(0.10), + ), + borderRadius: BorderRadius.circular(26), + ), + ), + ), addSettingsListener ? // We need this ValueListenableBuilder to react to changes to // showTextOnGridView. When shown in a MusicScreen, this widget @@ -88,19 +108,21 @@ class _AlbumItemCardText extends StatelessWidget { return Align( alignment: Alignment.bottomCenter, child: Container( + width: double.infinity, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [ - // We fade from half transparent black to transparent so that text is visible on bright images - Colors.black.withOpacity(0.5), + Colors.black.withOpacity(0.86), + Colors.black.withOpacity(0.24), Colors.transparent, ], + stops: const [0.0, 0.58, 1.0], ), ), child: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.fromLTRB(14, 46, 14, 14), child: Align( alignment: Alignment.bottomLeft, child: Column( @@ -110,19 +132,22 @@ class _AlbumItemCardText extends StatelessWidget { Text( item.name ?? "Unknown Name", overflow: TextOverflow.ellipsis, - maxLines: 3, - style: Theme.of(context) - .textTheme - .titleLarge! - .copyWith(color: Colors.white), + maxLines: 2, + style: Theme.of(context).textTheme.titleMedium!.copyWith( + color: Colors.white, + fontWeight: FontWeight.w800, + height: 1.05, + ), ), if (subtitle != null) Text( subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: Theme.of(context) .textTheme .bodySmall! - .copyWith(color: Colors.white.withOpacity(0.7)), + .copyWith(color: Colors.white.withOpacity(0.72)), ) ], ), @@ -131,4 +156,4 @@ class _AlbumItemCardText extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/components/MusicScreen/album_item_list_tile.dart b/lib/components/MusicScreen/album_item_list_tile.dart index dc33a6e..c0b6457 100644 --- a/lib/components/MusicScreen/album_item_list_tile.dart +++ b/lib/components/MusicScreen/album_item_list_tile.dart @@ -25,24 +25,61 @@ class AlbumItemListTile extends StatelessWidget { Widget build(BuildContext context) { final jellyfinApiHelper = GetIt.instance(); final subtitle = generateSubtitle(item, parentType, context); + final colorScheme = Theme.of(context).colorScheme; - return ListTile( - // This widget is used on the add to playlist screen, so we allow a custom - // onTap to be passed as an argument. - onTap: onTap, - leading: AlbumImage(item: item), - title: Text( - item.name ?? AppLocalizations.of(context)!.unknownName, - overflow: TextOverflow.ellipsis, + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + child: Material( + color: colorScheme.surfaceVariant.withOpacity(0.42), + borderRadius: BorderRadius.circular(22), + clipBehavior: Clip.antiAlias, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(22), + border: Border.all( + color: colorScheme.outlineVariant.withOpacity(0.24), + ), + ), + child: ListTile( + // This widget is used on the add to playlist screen, so we allow a + // custom onTap to be passed as an argument. + onTap: onTap, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + leading: SizedBox.square( + dimension: 62, + child: ClipRRect( + borderRadius: BorderRadius.circular(18), + child: AlbumImage(item: item), + ), + ), + title: Text( + item.name ?? AppLocalizations.of(context)!.unknownName, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + color: colorScheme.onSurface, + ), + ), + subtitle: subtitle == null + ? null + : Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + trailing: jellyfinApiHelper.selectedMixAlbumIds.contains(item.id) + ? Icon(Icons.explore_rounded, color: colorScheme.primary) + : Icon( + Icons.chevron_right_rounded, + color: colorScheme.onSurfaceVariant, + ), + ), + ), ), - subtitle: subtitle == null ? null : Text( - subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: jellyfinApiHelper.selectedMixAlbumIds.contains(item.id) - ? const Icon(Icons.explore) - : null, ); } } diff --git a/lib/components/error_snackbar.dart b/lib/components/error_snackbar.dart index f4ba304..ccfce25 100644 --- a/lib/components/error_snackbar.dart +++ b/lib/components/error_snackbar.dart @@ -3,29 +3,35 @@ import 'package:flutter/material.dart'; import 'package:finamp/l10n/app_localizations.dart'; /// Snackbar with error icon for displaying errors -ScaffoldFeatureController errorSnackbar( - dynamic error, BuildContext context) { - return ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context)!.anErrorHasOccured), - action: SnackBarAction( - label: MaterialLocalizations.of(context).moreButtonTooltip, - onPressed: () => showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(AppLocalizations.of(context)!.error), - content: Text(_errorText(error, context)), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(MaterialLocalizations.of(context).closeButtonLabel), - ) - ], +void errorSnackbar(dynamic error, BuildContext context) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.anErrorHasOccured), + action: SnackBarAction( + label: MaterialLocalizations.of(context).moreButtonTooltip, + onPressed: () => showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.error), + content: Text(_errorText(error, context)), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: + Text(MaterialLocalizations.of(context).closeButtonLabel), + ) + ], + ), ), ), ), - ), - ); + ); + }); } String _errorText(dynamic error, BuildContext context) { diff --git a/lib/main.dart b/lib/main.dart index 8c7a5fa..4664a5b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,7 +9,6 @@ import 'package:finamp/screens/interaction_settings_screen.dart'; import 'package:finamp/services/finamp_settings_helper.dart'; import 'package:finamp/services/finamp_user_helper.dart'; import 'package:finamp/services/offline_listen_helper.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_downloader/flutter_downloader.dart'; @@ -46,6 +45,7 @@ import 'screens/user_selector.dart'; import 'screens/view_selector.dart'; import 'services/audio_service_helper.dart'; import 'services/download_update_stream.dart'; +import 'services/download_support.dart'; import 'services/downloads_helper.dart'; import 'services/jellyfin_api_helper.dart'; import 'services/locale_helper.dart'; @@ -123,7 +123,7 @@ Future _setupDownloader() async { GetIt.instance.registerSingleton(DownloadUpdateStream()); GetIt.instance().setupSendPort(); - if (!_isDownloaderSupported) { + if (!isDownloaderSupported) { return; } @@ -137,15 +137,6 @@ Future _setupDownloader() async { FlutterDownloader.registerCallback(_DummyCallback.callback); } -bool get _isDownloaderSupported { - if (kIsWeb) { - return false; - } - - return defaultTargetPlatform == TargetPlatform.android || - defaultTargetPlatform == TargetPlatform.iOS; -} - Future setupHive() async { await Hive.initFlutter(); Hive.registerAdapter(BaseItemDtoAdapter()); @@ -369,6 +360,19 @@ class Finamp extends StatelessWidget { useMaterial3: true, brightness: Brightness.dark, colorScheme: darkColorScheme, + scaffoldBackgroundColor: darkColorScheme.background, + cardTheme: CardThemeData( + color: darkColorScheme.surfaceVariant, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(28), + ), + ), + appBarTheme: const AppBarTheme( + backgroundColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + ), ), themeMode: box.get("ThemeMode"), localizationsDelegates: const [ diff --git a/lib/models/finamp_models.dart b/lib/models/finamp_models.dart index b9ac0f3..7cd972f 100644 --- a/lib/models/finamp_models.dart +++ b/lib/models/finamp_models.dart @@ -9,6 +9,7 @@ import 'package:path/path.dart' as path_helper; import 'package:uuid/uuid.dart'; import '../services/finamp_settings_helper.dart'; +import '../services/download_support.dart'; import '../services/get_internal_song_dir.dart'; import 'jellyfin_models.dart'; @@ -474,6 +475,10 @@ class DownloadedSong { .finampSettings.downloadLocationsMap[downloadLocationId]; Future get downloadTask async { + if (!isDownloaderSupported) { + return null; + } + final tasks = await FlutterDownloader.loadTasksWithRawQuery( query: "SELECT * FROM task WHERE task_id = '$downloadId'"); @@ -553,6 +558,10 @@ class DownloadedImage { } Future get downloadTask async { + if (!isDownloaderSupported) { + return null; + } + final tasks = await FlutterDownloader.loadTasksWithRawQuery( query: "SELECT * FROM task WHERE task_id = '$downloadId'"); diff --git a/lib/screens/music_screen.dart b/lib/screens/music_screen.dart index 852a329..3ed82ff 100644 --- a/lib/screens/music_screen.dart +++ b/lib/screens/music_screen.dart @@ -265,91 +265,87 @@ class _MusicScreenState extends State padding: const EdgeInsets.only(right: 8.0), child: getFloatingActionButton(), ), - body: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Theme.of(context) - .colorScheme - .primaryContainer - .withOpacity(0.45), - Theme.of(context).colorScheme.surface, - Theme.of(context).colorScheme.surface, - ], - ), - ), - child: SafeArea( - top: false, - child: Column( - children: [ - if (!isSearching) - _MusicHeroHeader( - title: _finampUserHelper - .currentUser?.currentView?.name ?? - AppLocalizations.of(context)!.music, - activeTab: tabs - .elementAt(_tabController!.index) - .toLocalisedString(context), - isFavourite: finampSettings.isFavourite, - isOffline: finampSettings.isOffline, - onShuffle: () async { - try { - await _audioServiceHelper.shuffleAll( - FinampSettingsHelper - .finampSettings.isFavourite, - ); - } catch (e) { - errorSnackbar(e, context); - } - }, + body: Stack( + children: [ + const _MusicBackdrop(), + SafeArea( + top: false, + child: Column( + children: [ + if (!isSearching) + _MusicHeroHeader( + title: _finampUserHelper + .currentUser?.currentView?.name ?? + AppLocalizations.of(context)!.music, + activeTab: tabs + .elementAt(_tabController!.index) + .toLocalisedString(context), + isFavourite: finampSettings.isFavourite, + isOffline: finampSettings.isOffline, + onShuffle: () async { + try { + await _audioServiceHelper.shuffleAll( + FinampSettingsHelper + .finampSettings.isFavourite, + ); + } catch (e) { + errorSnackbar(e, context); + } + }, + ), + _ModernMusicTabBar( + controller: _tabController!, + tabs: tabs.toList(), ), - _ModernMusicTabBar( - controller: _tabController!, - tabs: tabs.toList(), - ), - Expanded( - child: Container( - margin: const EdgeInsets.fromLTRB(12, 8, 12, 0), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(28), - ), - boxShadow: [ - BoxShadow( - color: Theme.of(context) - .colorScheme - .shadow - .withOpacity(0.08), - blurRadius: 24, - offset: const Offset(0, -6), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 0), + child: Material( + color: Theme.of(context) + .colorScheme + .surface + .withOpacity(0.74), + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(34), + ), + side: BorderSide( + color: Theme.of(context) + .colorScheme + .outlineVariant + .withOpacity(0.42), + ), ), - ], - ), - clipBehavior: Clip.antiAlias, - child: TabBarView( - controller: _tabController, - children: tabs - .map((tabType) => MusicScreenTabView( - tabContentType: tabType, - searchTerm: searchQuery, - isFavourite: finampSettings.isFavourite, - sortBy: finampSettings - .getTabSortBy(tabType), - sortOrder: finampSettings - .getSortOrder(tabType), - view: _finampUserHelper - .currentUser?.currentView, - )) - .toList(), + elevation: 16, + shadowColor: Theme.of(context) + .colorScheme + .shadow + .withOpacity(0.24), + clipBehavior: Clip.antiAlias, + child: TabBarView( + controller: _tabController, + children: tabs + .map((tabType) => MusicScreenTabView( + tabContentType: tabType, + searchTerm: searchQuery, + isFavourite: + finampSettings.isFavourite, + sortBy: finampSettings + .getTabSortBy(tabType), + sortOrder: finampSettings + .getSortOrder(tabType), + view: _finampUserHelper + .currentUser?.currentView, + )) + .toList(), + ), + ), ), ), - ), - ], + ], + ), ), - ), + ], ), ), ); @@ -360,6 +356,78 @@ class _MusicScreenState extends State } } +class _MusicBackdrop extends StatelessWidget { + const _MusicBackdrop(); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + const Color(0xFF081D38), + colorScheme.background, + const Color(0xFF020611), + ], + ), + ), + child: Stack( + children: [ + Positioned( + top: -110, + right: -80, + child: _GlowOrb( + color: colorScheme.primary.withOpacity(0.28), + size: 280, + ), + ), + Positioned( + top: 90, + left: -120, + child: _GlowOrb( + color: colorScheme.tertiary.withOpacity(0.20), + size: 260, + ), + ), + Positioned( + bottom: -140, + right: -130, + child: _GlowOrb( + color: colorScheme.secondary.withOpacity(0.16), + size: 320, + ), + ), + ], + ), + ); + } +} + +class _GlowOrb extends StatelessWidget { + const _GlowOrb({required this.color, required this.size}); + + final Color color; + final double size; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [color, Colors.transparent], + ), + ), + ); + } +} + class _MusicHeroHeader extends StatelessWidget { const _MusicHeroHeader({ required this.title, @@ -381,73 +449,64 @@ class _MusicHeroHeader extends StatelessWidget { final colorScheme = theme.colorScheme; return Padding( - padding: const EdgeInsets.fromLTRB(20, 86, 20, 12), - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(32), - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - colorScheme.primary.withOpacity(0.95), - colorScheme.tertiary.withOpacity(0.78), - ], - ), - boxShadow: [ - BoxShadow( - color: colorScheme.primary.withOpacity(0.28), - blurRadius: 34, - offset: const Offset(0, 18), + padding: const EdgeInsets.fromLTRB(24, 88, 24, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isOffline ? 'OFFLINE MODE' : 'FINAMP MUSIC', + style: theme.textTheme.labelMedium?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w800, + letterSpacing: 2.2, ), - ], - ), - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + ), + const SizedBox(height: 10), + Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.displaySmall?.copyWith( + color: colorScheme.onBackground, + fontWeight: FontWeight.w900, + height: 0.98, + letterSpacing: -1.4, + ), + ), + const SizedBox(height: 12), + Text( + 'Browse your Jellyfin collection with artwork-first shelves.', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 22), + Row( children: [ - Text( - isOffline ? 'Offline library' : 'Your Jellyfin music', - style: theme.textTheme.labelLarge?.copyWith( - color: colorScheme.onPrimary.withOpacity(0.76), - letterSpacing: 0.6, + FilledButton.icon( + onPressed: onShuffle, + icon: const Icon(Icons.shuffle_rounded), + label: const Text('Shuffle'), + style: FilledButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + padding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 14, + ), ), ), - const SizedBox(height: 8), - Text( - title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.headlineMedium?.copyWith( - color: colorScheme.onPrimary, - fontWeight: FontWeight.w800, - height: 1.04, - ), - ), - const SizedBox(height: 20), - Row( - children: [ - FilledButton.icon( - onPressed: onShuffle, - icon: const Icon(Icons.shuffle_rounded), - label: const Text('Shuffle'), - style: FilledButton.styleFrom( - backgroundColor: colorScheme.onPrimary, - foregroundColor: colorScheme.primary, - ), - ), - const SizedBox(width: 10), - _HeroChip( - icon: isFavourite - ? Icons.favorite_rounded - : Icons.library_music_rounded, - label: isFavourite ? 'Favourites' : activeTab, - ), - ], + const SizedBox(width: 12), + _HeroChip( + icon: + isFavourite ? Icons.favorite_rounded : Icons.album_rounded, + label: isFavourite ? 'Favourites' : activeTab, ), ], ), - ), + ], ), ); } @@ -466,19 +525,19 @@ class _HeroChip extends StatelessWidget { return Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( - color: colorScheme.onPrimary.withOpacity(0.14), + color: colorScheme.surfaceVariant.withOpacity(0.58), borderRadius: BorderRadius.circular(999), - border: Border.all(color: colorScheme.onPrimary.withOpacity(0.16)), + border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.55)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, color: colorScheme.onPrimary, size: 18), + Icon(icon, color: colorScheme.secondary, size: 18), const SizedBox(width: 8), Text( label, style: TextStyle( - color: colorScheme.onPrimary, + color: colorScheme.onSurface, fontWeight: FontWeight.w700, ), ), @@ -502,10 +561,10 @@ class _ModernMusicTabBar extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12), child: DecoratedBox( decoration: BoxDecoration( - color: colorScheme.surface.withOpacity(0.7), + color: colorScheme.surfaceVariant.withOpacity(0.50), borderRadius: BorderRadius.circular(999), border: - Border.all(color: colorScheme.outlineVariant.withOpacity(0.4)), + Border.all(color: colorScheme.outlineVariant.withOpacity(0.55)), ), child: TabBar( controller: controller, @@ -518,7 +577,9 @@ class _ModernMusicTabBar extends StatelessWidget { indicatorSize: TabBarIndicatorSize.tab, indicator: BoxDecoration( borderRadius: BorderRadius.circular(999), - color: colorScheme.primary, + gradient: LinearGradient( + colors: [colorScheme.primary, colorScheme.secondary], + ), ), labelColor: colorScheme.onPrimary, unselectedLabelColor: colorScheme.onSurfaceVariant, diff --git a/lib/screens/player_screen.dart b/lib/screens/player_screen.dart index a2a46e5..7a78406 100644 --- a/lib/screens/player_screen.dart +++ b/lib/screens/player_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'dart:ui'; @@ -68,6 +69,7 @@ class PlayerScreen extends StatelessWidget { children: [ if (FinampSettingsHelper.finampSettings.showCoverAsPlayerBackground) const _BlurredPlayerScreenBackground(), + const _PlayerAmbientVisualizer(), SafeArea( child: LayoutBuilder( builder: (context, constraints) { @@ -113,6 +115,158 @@ class PlayerScreen extends StatelessWidget { } } +class _PlayerAmbientVisualizer extends StatefulWidget { + const _PlayerAmbientVisualizer({Key? key}) : super(key: key); + + @override + State<_PlayerAmbientVisualizer> createState() => + _PlayerAmbientVisualizerState(); +} + +class _PlayerAmbientVisualizerState extends State<_PlayerAmbientVisualizer> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + StreamSubscription? _subscription; + bool _isPlaying = false; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1800), + ); + + final audioHandler = GetIt.instance(); + _subscription = audioHandler.playbackState.listen((playbackState) { + if (_isPlaying == playbackState.playing) { + return; + } + + setState(() { + _isPlaying = playbackState.playing; + }); + + if (_isPlaying) { + _controller.repeat(); + } else { + _controller.stop(); + } + }); + } + + @override + void dispose() { + _subscription?.cancel(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return IgnorePointer( + child: AnimatedOpacity( + opacity: _isPlaying ? 1 : 0.18, + duration: const Duration(milliseconds: 450), + child: RepaintBoundary( + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: _AmbientVisualizerPainter( + progress: _controller.value, + primary: colorScheme.primary, + secondary: colorScheme.secondary, + tertiary: colorScheme.tertiary, + ), + size: Size.infinite, + ); + }, + ), + ), + ), + ); + } +} + +class _AmbientVisualizerPainter extends CustomPainter { + const _AmbientVisualizerPainter({ + required this.progress, + required this.primary, + required this.secondary, + required this.tertiary, + }); + + final double progress; + final Color primary; + final Color secondary; + final Color tertiary; + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width * 0.5, size.height * 0.42); + final pulse = 0.5 + sin(progress * pi * 2) * 0.5; + + _paintOrb( + canvas, + center.translate(size.width * -0.22, size.height * -0.08), + size.shortestSide * (0.34 + pulse * 0.05), + primary.withOpacity(0.16), + ); + _paintOrb( + canvas, + center.translate(size.width * 0.23, size.height * 0.02), + size.shortestSide * (0.30 + (1 - pulse) * 0.07), + tertiary.withOpacity(0.14), + ); + + final barPaint = Paint()..style = PaintingStyle.fill; + final barCount = max(16, (size.width / 28).floor()); + final maxBarHeight = size.height * 0.14; + final baseline = size.height * 0.86; + final totalWidth = min(size.width * 0.72, 620.0); + final gap = totalWidth / barCount; + final startX = (size.width - totalWidth) / 2; + + for (var i = 0; i < barCount; i++) { + final wave = sin(progress * pi * 2 + i * 0.62); + final alternateWave = sin(progress * pi * 4 + i * 0.31); + final normalized = (wave + alternateWave * 0.35 + 1.35) / 2.7; + final barHeight = 12 + normalized * maxBarHeight; + final x = startX + i * gap + gap * 0.22; + final width = gap * 0.48; + final rect = RRect.fromRectAndRadius( + Rect.fromLTWH(x, baseline - barHeight, width, barHeight), + Radius.circular(width), + ); + + barPaint.color = Color.lerp(primary, secondary, i / barCount)! + .withOpacity(0.16 + normalized * 0.26); + canvas.drawRRect(rect, barPaint); + } + } + + void _paintOrb(Canvas canvas, Offset center, double radius, Color color) { + final paint = Paint() + ..shader = RadialGradient( + colors: [color, Colors.transparent], + ).createShader(Rect.fromCircle(center: center, radius: radius)); + + canvas.drawCircle(center, radius, paint); + } + + @override + bool shouldRepaint(covariant _AmbientVisualizerPainter oldDelegate) { + return progress != oldDelegate.progress || + primary != oldDelegate.primary || + secondary != oldDelegate.secondary || + tertiary != oldDelegate.tertiary; + } +} + class _PlayerArtworkSurface extends StatelessWidget { const _PlayerArtworkSurface({Key? key}) : super(key: key); @@ -250,9 +404,9 @@ class _PlayerScreenAlbumImage extends ConsumerWidget { // We need a post frame callback because otherwise this // widget rebuilds on the same frame WidgetsBinding.instance.addPostFrameCallback( - (_) => ref.read(_albumImageProvider.notifier).state = - imageProvider, - ), + (_) => ref.read(_albumImageProvider.notifier).state = + imageProvider, + ), // Here we awkwardly get the next 3 queue items so that we // can precache them (so that the image is already loaded // when the next song comes on). diff --git a/lib/services/download_support.dart b/lib/services/download_support.dart new file mode 100644 index 0000000..c5de4f1 --- /dev/null +++ b/lib/services/download_support.dart @@ -0,0 +1,10 @@ +import 'package:flutter/foundation.dart'; + +bool get isDownloaderSupported { + if (kIsWeb) { + return false; + } + + return defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS; +} diff --git a/lib/services/download_update_stream.dart b/lib/services/download_update_stream.dart index d7d1a2c..9de6a0c 100644 --- a/lib/services/download_update_stream.dart +++ b/lib/services/download_update_stream.dart @@ -31,7 +31,7 @@ class DownloadUpdateStream { _port.sendPort, 'downloader_send_port'); _port.listen((dynamic data) { String id = data[0]; - DownloadTaskStatus status = DownloadTaskStatus(data[1]); + DownloadTaskStatus status = DownloadTaskStatus.fromInt(data[1]); int progress = data[2]; add(DownloadUpdate( diff --git a/lib/services/downloads_helper.dart b/lib/services/downloads_helper.dart index f7c176a..93d6786 100644 --- a/lib/services/downloads_helper.dart +++ b/lib/services/downloads_helper.dart @@ -11,6 +11,7 @@ import 'package:path/path.dart' as path_helper; import 'finamp_settings_helper.dart'; import 'finamp_user_helper.dart'; +import 'download_support.dart'; import 'jellyfin_api.dart'; import 'jellyfin_api_helper.dart'; import 'get_internal_song_dir.dart'; @@ -61,6 +62,10 @@ class DownloadsHelper { /// The view that this download is in. Used for sorting in offline mode. required String viewId, }) async { + if (!isDownloaderSupported) { + return Future.error("Downloads are only supported on Android and iOS"); + } + // Check if we have external storage permission. It's a bit of a hack, but // we only do this if downloadLocation.deletable is true because if it's // true, we're downloading to a user location. You wouldn't want the app @@ -228,6 +233,10 @@ class DownloadsHelper { /// Gets the download status for the given item ids (Jellyfin item id, not flutter_downloader task id). Future?> getDownloadStatus(List itemIds) async { try { + if (!isDownloaderSupported) { + return []; + } + List downloadIds = []; for (final itemId in itemIds) { @@ -248,7 +257,8 @@ class DownloadsHelper { } } - Future removeChildFromParent({required String parentId, required List childIds}) async { + Future removeChildFromParent( + {required String parentId, required List childIds}) async { var album = _downloadedParentsBox.get(parentId); for (String childId in childIds) { album?.downloadedChildren.removeWhere((key, value) => key == childId); @@ -266,17 +276,18 @@ class DownloadsHelper { _downloadsLogger.info( "Could not find $jellyfinItemId in downloadedItemsBox, assuming already deleted"); } else { - DownloadedImage? downloadedImage = getDownloadedImage(downloadedSong.song); + DownloadedImage? downloadedImage = + getDownloadedImage(downloadedSong.song); downloadedImage?.requiredBy.remove(jellyfinItemId); if (downloadedSong.requiredBy.isEmpty) { - _downloadsLogger.info( - "Item $jellyfinItemId has no dependencies, deleting files"); + _downloadsLogger + .info("Item $jellyfinItemId has no dependencies, deleting files"); _downloadsLogger.info( "Deleting ${downloadedSong.downloadId} from flutter_downloader"); - deleteDownloadFutures.add(FlutterDownloader.remove( + deleteDownloadFutures.add(_removeDownload( taskId: downloadedSong.downloadId, - shouldDeleteContent: true, + file: downloadedSong.file, )); await _downloadedItemsBox.delete(jellyfinItemId); @@ -308,7 +319,8 @@ class DownloadsHelper { /// This function will delete all given jellyfinItemIds regardless if they are still used /// by other albums/playlists - Future deleteDownloadChildren({required List jellyfinItemIds, String? deletedFor}) async { + Future deleteDownloadChildren( + {required List jellyfinItemIds, String? deletedFor}) async { final List deleteDownloadFutures = []; final Map directoriesToCheck = {}; @@ -321,11 +333,11 @@ class DownloadsHelper { "Could not find $jellyfinItemId in downloadedItemsBox, assuming already deleted"); } else { DownloadedImage? downloadedImage = - getDownloadedImage(downloadedSong.song); + getDownloadedImage(downloadedSong.song); if (deletedFor != null) { - _downloadsLogger - .info("Removing $deletedFor dependency from $jellyfinItemId, current dependencies ${downloadedSong.requiredBy}"); + _downloadsLogger.info( + "Removing $deletedFor dependency from $jellyfinItemId, current dependencies ${downloadedSong.requiredBy}"); downloadedSong.requiredBy.removeWhere((item) => item == deletedFor); } @@ -337,9 +349,9 @@ class DownloadsHelper { _downloadsLogger.info( "Deleting ${downloadedSong.downloadId} from flutter_downloader"); - deleteDownloadFutures.add(FlutterDownloader.remove( + deleteDownloadFutures.add(_removeDownload( taskId: downloadedSong.downloadId, - shouldDeleteContent: true, + file: downloadedSong.file, )); await _downloadedItemsBox.delete(jellyfinItemId); @@ -347,7 +359,7 @@ class DownloadsHelper { if (deletedFor != null) { DownloadedParent? downloadedAlbumTemp = - _downloadedParentsBox.get(deletedFor); + _downloadedParentsBox.get(deletedFor); if (downloadedAlbumTemp != null) { downloadedAlbumTemp.downloadedChildren.remove(jellyfinItemId); await _downloadedParentsBox.put(deletedFor, downloadedAlbumTemp); @@ -417,7 +429,8 @@ class DownloadsHelper { String? deletedFor, }) async { try { - await deleteDownloadChildren(jellyfinItemIds: jellyfinItemIds, deletedFor: deletedFor); + await deleteDownloadChildren( + jellyfinItemIds: jellyfinItemIds, deletedFor: deletedFor); if (deletedFor != null) { await deleteDownloadParent(deletedFor: deletedFor); } @@ -445,12 +458,27 @@ class DownloadsHelper { _downloadedImagesBox.delete(downloadedImage.id); _downloadedImageIdsBox.delete(downloadedImage.downloadId); - await FlutterDownloader.remove( + await _removeDownload( taskId: downloadedImage.downloadId, - shouldDeleteContent: true, + file: downloadedImage.file, ); } + Future _removeDownload( + {required String taskId, required File file}) async { + if (isDownloaderSupported) { + await FlutterDownloader.remove( + taskId: taskId, + shouldDeleteContent: true, + ); + return; + } + + if (await file.exists()) { + await file.delete(); + } + } + /// Calculates the total file size of the given directory. /// Returns the total file size in bytes. /// Returns 0 if the directory doesn't exist. @@ -480,6 +508,10 @@ class DownloadsHelper { Future?> getIncompleteDownloads() async { try { + if (!isDownloaderSupported) { + return []; + } + return await FlutterDownloader.loadTasksWithRawQuery( query: "SELECT * FROM task WHERE status <> 3"); } catch (e) { @@ -491,9 +523,13 @@ class DownloadsHelper { Future?> getDownloadsWithStatus( DownloadTaskStatus downloadTaskStatus) async { try { + if (!isDownloaderSupported) { + return []; + } + return await FlutterDownloader.loadTasksWithRawQuery( query: - "SELECT * FROM task WHERE status = ${downloadTaskStatus.value}"); + "SELECT * FROM task WHERE status = ${downloadTaskStatus.index}"); } catch (e) { _downloadsLogger.severe(e); return Future.error(e); @@ -851,6 +887,10 @@ class DownloadsHelper { /// creating new ones with the same settings. Returns number of songs /// redownloaded Future redownloadFailed() async { + if (!isDownloaderSupported) { + return 0; + } + final failedDownloadTasks = await getDownloadsWithStatus(DownloadTaskStatus.failed); @@ -883,8 +923,8 @@ class DownloadsHelper { for (String parent in parents) { // We don't specify deletedFor here because it could cause the parent // to get deleted - deleteFutures - .add(deleteParentAndChildDownloads(jellyfinItemIds: [downloadedSong.song.id])); + deleteFutures.add(deleteParentAndChildDownloads( + jellyfinItemIds: [downloadedSong.song.id])); if (parentItems[downloadedSong.song.id] == null) { parentItems[downloadedSong.song.id] = []; @@ -1055,7 +1095,6 @@ class DownloadsHelper { {List? keys}) => _downloadedItemsBox.listenable(keys: keys); - /// Converts a dart list to a string with the correct SQL syntax String _dartListToSqlList(List dartList) { try { @@ -1106,6 +1145,10 @@ class DownloadsHelper { }) async { assert(item.blurHash != null); + if (!isDownloaderSupported) { + return Future.error("Downloads are only supported on Android and iOS"); + } + if (_downloadedImagesBox.containsKey(item.blurHash)) return; final imageUrl = _jellyfinApiData.getImageUrl( diff --git a/pubspec.lock b/pubspec.lock index ec4f8e4..7a0c939 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -369,12 +369,11 @@ packages: flutter_downloader: dependency: "direct main" description: - path: "." - ref: db66f36ec48dc50117e510e2f37995cc17b44534 - resolved-ref: db66f36ec48dc50117e510e2f37995cc17b44534 - url: "https://github.com/jmshrv/flutter_downloader.git" - source: git - version: "1.10.4" + name: flutter_downloader + sha256: "93a9ddbd561f8a3f5483b4189453fba145a0a1014a88143c96a966296b78a118" + url: "https://pub.dev" + source: hosted + version: "1.12.0" flutter_launcher_icons: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index d7b8a84..fb1a145 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,10 +34,7 @@ dependencies: audio_session: ^0.1.21 rxdart: ^0.28.0 simple_gesture_detector: ^0.2.0 - flutter_downloader: - git: - url: https://github.com/jmshrv/flutter_downloader.git - ref: "db66f36ec48dc50117e510e2f37995cc17b44534" + flutter_downloader: ^1.12.0 path_provider: ^2.0.14 hive: ^2.2.3 hive_flutter: ^1.1.0