redesign app phase 3
Build / Build for Android (push) Has been cancelled
Build / Build for iOS (push) Has been cancelled

This commit is contained in:
Zakaria
2026-05-19 16:41:18 -04:00
parent c36fc55102
commit 9eb6b5237c
20 changed files with 645 additions and 275 deletions
+13 -2
View File
@@ -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
+2 -2
View File
@@ -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"
}
}
}
+5 -1
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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"
+22 -22
View File
@@ -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),
);
);
@@ -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<CurrentDownloadsList> {
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<CurrentDownloadsList> {
@override
void dispose() {
IsolateNameServer.removePortNameMapping('downloader_send_port');
if (isDownloaderSupported) {
IsolateNameServer.removePortNameMapping('downloader_send_port');
}
super.dispose();
}
@@ -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<DownloadsOverview> {
@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.
+40 -15
View File
@@ -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 {
),
);
}
}
}
@@ -25,24 +25,61 @@ class AlbumItemListTile extends StatelessWidget {
Widget build(BuildContext context) {
final jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
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,
);
}
}
+26 -20
View File
@@ -3,29 +3,35 @@ import 'package:flutter/material.dart';
import 'package:finamp/l10n/app_localizations.dart';
/// Snackbar with error icon for displaying errors
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> 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) {
+15 -11
View File
@@ -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<void> _setupDownloader() async {
GetIt.instance.registerSingleton(DownloadUpdateStream());
GetIt.instance<DownloadUpdateStream>().setupSendPort();
if (!_isDownloaderSupported) {
if (!isDownloaderSupported) {
return;
}
@@ -137,15 +137,6 @@ Future<void> _setupDownloader() async {
FlutterDownloader.registerCallback(_DummyCallback.callback);
}
bool get _isDownloaderSupported {
if (kIsWeb) {
return false;
}
return defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS;
}
Future<void> 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 [
+9
View File
@@ -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<DownloadTask?> 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<DownloadTask?> get downloadTask async {
if (!isDownloaderSupported) {
return null;
}
final tasks = await FlutterDownloader.loadTasksWithRawQuery(
query: "SELECT * FROM task WHERE task_id = '$downloadId'");
+208 -147
View File
@@ -265,91 +265,87 @@ class _MusicScreenState extends State<MusicScreen>
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<MusicScreen>
}
}
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,
+157 -3
View File
@@ -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<PlaybackState>? _subscription;
bool _isPlaying = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1800),
);
final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
_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).
+10
View File
@@ -0,0 +1,10 @@
import 'package:flutter/foundation.dart';
bool get isDownloaderSupported {
if (kIsWeb) {
return false;
}
return defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS;
}
+1 -1
View File
@@ -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(
+63 -20
View File
@@ -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<List<DownloadTask>?> getDownloadStatus(List<String> itemIds) async {
try {
if (!isDownloaderSupported) {
return [];
}
List<String> downloadIds = [];
for (final itemId in itemIds) {
@@ -248,7 +257,8 @@ class DownloadsHelper {
}
}
Future<void> removeChildFromParent({required String parentId, required List<String> childIds}) async {
Future<void> removeChildFromParent(
{required String parentId, required List<String> 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<void> deleteDownloadChildren({required List<String> jellyfinItemIds, String? deletedFor}) async {
Future<void> deleteDownloadChildren(
{required List<String> jellyfinItemIds, String? deletedFor}) async {
final List<Future> deleteDownloadFutures = [];
final Map<String, Directory> 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<void> _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<List<DownloadTask>?> 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<List<DownloadTask>?> 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<int> 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<String>? 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(
+5 -6
View File
@@ -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:
+1 -4
View File
@@ -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