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

This commit is contained in:
Zakaria
2026-05-18 16:17:32 -04:00
parent d0ca84d8d2
commit c36fc55102
54 changed files with 2371 additions and 276 deletions
+84 -68
View File
@@ -9,6 +9,7 @@ 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';
@@ -122,6 +123,10 @@ Future<void> _setupDownloader() async {
GetIt.instance.registerSingleton(DownloadUpdateStream());
GetIt.instance<DownloadUpdateStream>().setupSendPort();
if (!_isDownloaderSupported) {
return;
}
WidgetsFlutterBinding.ensureInitialized();
await FlutterDownloader.initialize(debug: true);
@@ -132,6 +137,15 @@ 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());
@@ -301,76 +315,78 @@ class Finamp extends StatelessWidget {
valueListenable: LocaleHelper.localeListener,
builder: (_, __, ___) {
return ValueListenableBuilder<Box<ThemeMode>>(
valueListenable: ThemeModeHelper.themeModeListener,
builder: (_, box, __) {
return MaterialApp(
title: "Finamp",
routes: {
SplashScreen.routeName: (context) => const SplashScreen(),
UserSelector.routeName: (context) => const UserSelector(),
ViewSelector.routeName: (context) => const ViewSelector(),
MusicScreen.routeName: (context) => const MusicScreen(),
AlbumScreen.routeName: (context) => const AlbumScreen(),
ArtistScreen.routeName: (context) => const ArtistScreen(),
AddToPlaylistScreen.routeName: (context) =>
const AddToPlaylistScreen(),
PlayerScreen.routeName: (context) => const PlayerScreen(),
DownloadsScreen.routeName: (context) =>
const DownloadsScreen(),
DownloadsErrorScreen.routeName: (context) =>
const DownloadsErrorScreen(),
LogsScreen.routeName: (context) => const LogsScreen(),
SettingsScreen.routeName: (context) =>
const SettingsScreen(),
TranscodingSettingsScreen.routeName: (context) =>
const TranscodingSettingsScreen(),
DownloadsSettingsScreen.routeName: (context) =>
const DownloadsSettingsScreen(),
AddDownloadLocationScreen.routeName: (context) =>
const AddDownloadLocationScreen(),
AudioServiceSettingsScreen.routeName: (context) =>
const AudioServiceSettingsScreen(),
InteractionSettingsScreen.routeName: (context) =>
const InteractionSettingsScreen(),
TabsSettingsScreen.routeName: (context) =>
const TabsSettingsScreen(),
LayoutSettingsScreen.routeName: (context) =>
const LayoutSettingsScreen(),
LanguageSelectionScreen.routeName: (context) =>
const LanguageSelectionScreen(),
},
initialRoute: SplashScreen.routeName,
theme: ThemeData(
brightness: Brightness.light,
colorScheme: lightColorScheme,
appBarTheme: const AppBarTheme(
systemOverlayStyle: SystemUiOverlayStyle(
statusBarBrightness: Brightness.light,
statusBarIconBrightness: Brightness.dark,
),
valueListenable: ThemeModeHelper.themeModeListener,
builder: (_, box, __) {
return MaterialApp(
title: "Finamp",
routes: {
SplashScreen.routeName: (context) => const SplashScreen(),
UserSelector.routeName: (context) => const UserSelector(),
ViewSelector.routeName: (context) => const ViewSelector(),
MusicScreen.routeName: (context) => const MusicScreen(),
AlbumScreen.routeName: (context) => const AlbumScreen(),
ArtistScreen.routeName: (context) => const ArtistScreen(),
AddToPlaylistScreen.routeName: (context) =>
const AddToPlaylistScreen(),
PlayerScreen.routeName: (context) => const PlayerScreen(),
DownloadsScreen.routeName: (context) =>
const DownloadsScreen(),
DownloadsErrorScreen.routeName: (context) =>
const DownloadsErrorScreen(),
LogsScreen.routeName: (context) => const LogsScreen(),
SettingsScreen.routeName: (context) =>
const SettingsScreen(),
TranscodingSettingsScreen.routeName: (context) =>
const TranscodingSettingsScreen(),
DownloadsSettingsScreen.routeName: (context) =>
const DownloadsSettingsScreen(),
AddDownloadLocationScreen.routeName: (context) =>
const AddDownloadLocationScreen(),
AudioServiceSettingsScreen.routeName: (context) =>
const AudioServiceSettingsScreen(),
InteractionSettingsScreen.routeName: (context) =>
const InteractionSettingsScreen(),
TabsSettingsScreen.routeName: (context) =>
const TabsSettingsScreen(),
LayoutSettingsScreen.routeName: (context) =>
const LayoutSettingsScreen(),
LanguageSelectionScreen.routeName: (context) =>
const LanguageSelectionScreen(),
},
initialRoute: SplashScreen.routeName,
theme: ThemeData(
useMaterial3: true,
brightness: Brightness.light,
colorScheme: lightColorScheme,
appBarTheme: const AppBarTheme(
systemOverlayStyle: SystemUiOverlayStyle(
statusBarBrightness: Brightness.light,
statusBarIconBrightness: Brightness.dark,
),
),
darkTheme: ThemeData(
brightness: Brightness.dark,
colorScheme: darkColorScheme,
),
themeMode: box.get("ThemeMode"),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
// We awkwardly put English as the first supported locale so
// that basicLocaleListResolution falls back to it instead of
// the first language in supportedLocales (Arabic as of writing)
localeListResolutionCallback: (locales, supportedLocales) =>
basicLocaleListResolution(locales,
[const Locale("en")].followedBy(supportedLocales)),
locale: LocaleHelper.locale,
);
},
),
darkTheme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: darkColorScheme,
),
themeMode: box.get("ThemeMode"),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
// We awkwardly put English as the first supported locale so
// that basicLocaleListResolution falls back to it instead of
// the first language in supportedLocales (Arabic as of writing)
localeListResolutionCallback: (locales, supportedLocales) =>
basicLocaleListResolution(locales,
[const Locale("en")].followedBy(supportedLocales)),
locale: LocaleHelper.locale,
);
},
);
},
),
+279 -30
View File
@@ -52,6 +52,7 @@ class _MusicScreenState extends State<MusicScreen>
.where((element) => element.value)
.elementAt(_tabController!.index)
.key;
setState(() {});
if (_tabController != null &&
(tabKey == TabContentType.songs ||
tabKey == TabContentType.artists ||
@@ -186,7 +187,11 @@ class _MusicScreenState extends State<MusicScreen>
return true;
},
child: Scaffold(
extendBody: true,
appBar: AppBar(
elevation: 0,
scrolledUnderElevation: 0,
backgroundColor: Colors.transparent,
title: isSearching
? TextField(
controller: textEditingController,
@@ -200,25 +205,19 @@ class _MusicScreenState extends State<MusicScreen>
.searchFieldLabel,
),
)
: Text(_finampUserHelper.currentUser?.currentView?.name ??
AppLocalizations.of(context)!.music),
bottom: TabBar(
controller: _tabController,
tabs: tabs
.map((tabType) => Tab(
text: tabType
.toLocalisedString(context)
.toUpperCase(),
))
.toList(),
isScrollable: true,
tabAlignment: TabAlignment.start,
),
: null,
leading: isSearching
? BackButton(
onPressed: () => _stopSearching(),
)
: null,
: Builder(
builder: (context) => IconButton.filledTonal(
icon: const Icon(Icons.menu_rounded),
onPressed: () => Scaffold.of(context).openDrawer(),
tooltip: MaterialLocalizations.of(context)
.openAppDrawerTooltip,
),
),
actions: isSearching
? [
IconButton(
@@ -242,8 +241,8 @@ class _MusicScreenState extends State<MusicScreen>
),
IconButton(
icon: finampSettings.isFavourite
? const Icon(Icons.favorite)
: const Icon(Icons.favorite_outline),
? const Icon(Icons.favorite_rounded)
: const Icon(Icons.favorite_outline_rounded),
onPressed: finampSettings.isOffline
? null
: () => FinampSettingsHelper.setIsFavourite(
@@ -251,7 +250,7 @@ class _MusicScreenState extends State<MusicScreen>
tooltip: AppLocalizations.of(context)!.favourites,
),
IconButton(
icon: const Icon(Icons.search),
icon: const Icon(Icons.search_rounded),
onPressed: () => setState(() {
isSearching = true;
}),
@@ -266,18 +265,91 @@ class _MusicScreenState extends State<MusicScreen>
padding: const EdgeInsets.only(right: 8.0),
child: getFloatingActionButton(),
),
body: TabBarView(
controller: _tabController,
children: tabs
.map((tabType) => MusicScreenTabView(
tabContentType: tabType,
searchTerm: searchQuery,
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,
sortBy: finampSettings.getTabSortBy(tabType),
sortOrder: finampSettings.getSortOrder(tabType),
view: _finampUserHelper.currentUser?.currentView,
))
.toList(),
isOffline: finampSettings.isOffline,
onShuffle: () async {
try {
await _audioServiceHelper.shuffleAll(
FinampSettingsHelper
.finampSettings.isFavourite,
);
} catch (e) {
errorSnackbar(e, context);
}
},
),
_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),
),
],
),
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(),
),
),
),
],
),
),
),
),
);
@@ -286,4 +358,181 @@ class _MusicScreenState extends State<MusicScreen>
},
);
}
}
}
class _MusicHeroHeader extends StatelessWidget {
const _MusicHeroHeader({
required this.title,
required this.activeTab,
required this.isFavourite,
required this.isOffline,
required this.onShuffle,
});
final String title;
final String activeTab;
final bool isFavourite;
final bool isOffline;
final VoidCallback onShuffle;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
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),
),
],
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isOffline ? 'Offline library' : 'Your Jellyfin music',
style: theme.textTheme.labelLarge?.copyWith(
color: colorScheme.onPrimary.withOpacity(0.76),
letterSpacing: 0.6,
),
),
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,
),
],
),
],
),
),
),
);
}
}
class _HeroChip extends StatelessWidget {
const _HeroChip({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: colorScheme.onPrimary.withOpacity(0.14),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: colorScheme.onPrimary.withOpacity(0.16)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: colorScheme.onPrimary, size: 18),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
color: colorScheme.onPrimary,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
}
class _ModernMusicTabBar extends StatelessWidget {
const _ModernMusicTabBar({required this.controller, required this.tabs});
final TabController controller;
final List<TabContentType> tabs;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: DecoratedBox(
decoration: BoxDecoration(
color: colorScheme.surface.withOpacity(0.7),
borderRadius: BorderRadius.circular(999),
border:
Border.all(color: colorScheme.outlineVariant.withOpacity(0.4)),
),
child: TabBar(
controller: controller,
tabs: tabs
.map((tabType) => Tab(text: tabType.toLocalisedString(context)))
.toList(),
isScrollable: true,
tabAlignment: TabAlignment.start,
dividerColor: Colors.transparent,
indicatorSize: TabBarIndicatorSize.tab,
indicator: BoxDecoration(
borderRadius: BorderRadius.circular(999),
color: colorScheme.primary,
),
labelColor: colorScheme.onPrimary,
unselectedLabelColor: colorScheme.onSurfaceVariant,
labelStyle: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
),
unselectedLabelStyle:
Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
padding: const EdgeInsets.all(4),
labelPadding: const EdgeInsets.symmetric(horizontal: 18),
),
),
);
}
}
+13 -2
View File
@@ -57,7 +57,7 @@ class ChopperAggregateLogger implements Logger {
}
void onEndRequest(chopper.Request request) {
info(_requests.remove(request)?.toString().trim());
info(_censorSensitiveLog(_requests.remove(request)?.toString().trim()));
}
void onStartResponse(chopper.Response response) {
@@ -65,7 +65,18 @@ class ChopperAggregateLogger implements Logger {
}
void onEndResponse(chopper.Response response) {
info(_responses.remove(response)?.toString().trim());
info(_censorSensitiveLog(_responses.remove(response)?.toString().trim()));
}
String? _censorSensitiveLog(String? message) {
if (message == null) {
return null;
}
return message
.replaceAll(RegExp(r'Token="[^"]*"'), 'Token="TOKEN"')
.replaceAll(RegExp(r'"Username":"[^"]*"'), '"Username":"USER"')
.replaceAll(RegExp(r'"Pw":"[^"]*"'), '"Pw":"PASSWORD"');
}
@override
+13 -3
View File
@@ -4,6 +4,7 @@ import 'package:android_id/android_id.dart';
import 'package:chopper/chopper.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:finamp/services/http_aggregate_logging_interceptor.dart';
import 'package:flutter/foundation.dart';
import 'package:get_it/get_it.dart';
import 'package:package_info_plus/package_info_plus.dart';
@@ -422,19 +423,28 @@ Future<String> getAuthHeader() async {
}
authHeader = '${authHeader}Client="Finamp", ';
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
if (Platform.isAndroid) {
if (kIsWeb) {
authHeader = '${authHeader}Device="Web Browser", ';
authHeader = '${authHeader}DeviceId="finamp-web", ';
} else if (Platform.isAndroid) {
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
AndroidDeviceInfo androidDeviceInfo = await deviceInfo.androidInfo;
authHeader = '${authHeader}Device="${androidDeviceInfo.model}", ';
final androidId = await const AndroidId().getId();
authHeader = '${authHeader}DeviceId="$androidId", ';
} else if (Platform.isIOS) {
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
IosDeviceInfo iosDeviceInfo = await deviceInfo.iosInfo;
authHeader = '${authHeader}Device="${iosDeviceInfo.name}", ';
authHeader =
'${authHeader}DeviceId="${iosDeviceInfo.identifierForVendor}", ';
} else {
throw "getAuthHeader() only supports Android and iOS";
final deviceName = "Finamp ${Platform.operatingSystem}";
final deviceId =
"finamp-${Platform.operatingSystem}-${Platform.localHostname}";
authHeader = '${authHeader}Device="$deviceName", ';
authHeader = '${authHeader}DeviceId="$deviceId", ';
}
PackageInfo packageInfo = await PackageInfo.fromPlatform();
+1 -10
View File
@@ -702,21 +702,12 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler {
}
Future<Uri> _songUri(MediaItem mediaItem) async {
// We need the platform to be Android or iOS to get device info
assert(Platform.isAndroid || Platform.isIOS,
"_songUri() only supports Android and iOS");
// When creating the MediaItem (usually in AudioServiceHelper), we specify
// whether or not to transcode. We used to pull from FinampSettings here,
// but since audio_service runs in an isolate (or at least, it does until
// 0.18), the value would be wrong if changed while a song was playing since
// Hive is bad at multi-isolate stuff.
final androidId =
Platform.isAndroid ? await const AndroidId().getId() : null;
final iosDeviceInfo =
Platform.isIOS ? await DeviceInfoPlugin().iosInfo : null;
final parsedBaseUrl = Uri.parse(_finampUserHelper.currentUser!.baseUrl);
List<String> builtPath = List.from(parsedBaseUrl.pathSegments);
@@ -795,4 +786,4 @@ String _jellyfinRepeatModeFromRepeatMode(AudioServiceRepeatMode repeatMode) {
case AudioServiceRepeatMode.group:
return "RepeatAll";
}
}
}