Files
Redesnigned-Finamp/lib/screens/music_screen.dart
T
Zakaria c36fc55102
Build / Build for Android (push) Has been cancelled
Build / Build for iOS (push) Has been cancelled
redesign app phase 2
2026-05-18 16:17:32 -04:00

539 lines
19 KiB
Dart

import 'package:flutter/material.dart';
import 'package:finamp/l10n/app_localizations.dart';
import 'package:get_it/get_it.dart';
import 'package:hive/hive.dart';
import 'package:logging/logging.dart';
import '../models/finamp_models.dart';
import '../services/finamp_settings_helper.dart';
import '../services/audio_service_helper.dart';
import '../services/finamp_user_helper.dart';
import '../components/MusicScreen/music_screen_tab_view.dart';
import '../components/MusicScreen/music_screen_drawer.dart';
import '../components/MusicScreen/sort_by_menu_button.dart';
import '../components/MusicScreen/sort_order_button.dart';
import '../components/now_playing_bar.dart';
import '../components/error_snackbar.dart';
import '../services/jellyfin_api_helper.dart';
class MusicScreen extends StatefulWidget {
const MusicScreen({Key? key}) : super(key: key);
static const routeName = "/music";
@override
State<MusicScreen> createState() => _MusicScreenState();
}
class _MusicScreenState extends State<MusicScreen>
with TickerProviderStateMixin {
bool isSearching = false;
bool _showShuffleFab = false;
TextEditingController textEditingController = TextEditingController();
String? searchQuery;
final _musicScreenLogger = Logger("MusicScreen");
TabController? _tabController;
final _audioServiceHelper = GetIt.instance<AudioServiceHelper>();
final _finampUserHelper = GetIt.instance<FinampUserHelper>();
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
void _stopSearching() {
setState(() {
textEditingController.clear();
searchQuery = null;
isSearching = false;
});
}
void _tabIndexCallback() {
var tabKey = FinampSettingsHelper.finampSettings.showTabs.entries
.where((element) => element.value)
.elementAt(_tabController!.index)
.key;
setState(() {});
if (_tabController != null &&
(tabKey == TabContentType.songs ||
tabKey == TabContentType.artists ||
tabKey == TabContentType.albums)) {
setState(() {
_showShuffleFab = true;
});
} else {
if (_showShuffleFab) {
setState(() {
_showShuffleFab = false;
});
}
}
}
void _buildTabController() {
_tabController?.removeListener(_tabIndexCallback);
_tabController = TabController(
length: FinampSettingsHelper.finampSettings.showTabs.entries
.where((element) => element.value)
.length,
vsync: this,
);
_tabController!.addListener(_tabIndexCallback);
}
@override
void initState() {
super.initState();
_buildTabController();
}
@override
void dispose() {
_tabController?.dispose();
super.dispose();
}
FloatingActionButton? getFloatingActionButton() {
var tabList = FinampSettingsHelper.finampSettings.showTabs.entries
.where((element) => element.value)
.map((e) => e.key)
.toList();
// Show the floating action button only on the albums, artists and songs tab.
if (_tabController!.index == tabList.indexOf(TabContentType.songs)) {
return FloatingActionButton(
tooltip: AppLocalizations.of(context)!.shuffleAll,
onPressed: () async {
try {
await _audioServiceHelper
.shuffleAll(FinampSettingsHelper.finampSettings.isFavourite);
} catch (e) {
errorSnackbar(e, context);
}
},
child: const Icon(Icons.shuffle),
);
} else if (_tabController!.index ==
tabList.indexOf(TabContentType.artists)) {
return FloatingActionButton(
tooltip: AppLocalizations.of(context)!.startMix,
onPressed: () async {
try {
if (_jellyfinApiHelper.selectedMixArtistsIds.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
AppLocalizations.of(context)!.startMixNoSongsArtist)));
} else {
await _audioServiceHelper.startInstantMixForArtists(
_jellyfinApiHelper.selectedMixArtistsIds);
}
} catch (e) {
errorSnackbar(e, context);
}
},
child: const Icon(Icons.explore));
} else if (_tabController!.index ==
tabList.indexOf(TabContentType.albums)) {
return FloatingActionButton(
tooltip: AppLocalizations.of(context)!.startMix,
onPressed: () async {
try {
if (_jellyfinApiHelper.selectedMixAlbumIds.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
AppLocalizations.of(context)!.startMixNoSongsAlbum)));
} else {
await _audioServiceHelper.startInstantMixForAlbums(
_jellyfinApiHelper.selectedMixAlbumIds);
}
} catch (e) {
errorSnackbar(e, context);
}
},
child: const Icon(Icons.explore));
} else {
return null;
}
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Box<FinampUser>>(
valueListenable: _finampUserHelper.finampUsersListenable,
builder: (context, value, _) {
return ValueListenableBuilder<Box<FinampSettings>>(
valueListenable: FinampSettingsHelper.finampSettingsListener,
builder: (context, value, _) {
final finampSettings = value.get("FinampSettings");
// Get the tabs from the user's tab order, and filter them to only
// include enabled tabs
final tabs = finampSettings!.tabOrder.where((e) =>
FinampSettingsHelper.finampSettings.showTabs[e] ?? false);
if (tabs.length != _tabController?.length) {
_musicScreenLogger.info(
"Rebuilding MusicScreen tab controller (${tabs.length} != ${_tabController?.length})");
_buildTabController();
}
return WillPopScope(
onWillPop: () async {
if (isSearching) {
_stopSearching();
return false;
}
return true;
},
child: Scaffold(
extendBody: true,
appBar: AppBar(
elevation: 0,
scrolledUnderElevation: 0,
backgroundColor: Colors.transparent,
title: isSearching
? TextField(
controller: textEditingController,
autofocus: true,
onChanged: (value) => setState(() {
searchQuery = value;
}),
decoration: InputDecoration(
border: InputBorder.none,
hintText: MaterialLocalizations.of(context)
.searchFieldLabel,
),
)
: null,
leading: isSearching
? BackButton(
onPressed: () => _stopSearching(),
)
: Builder(
builder: (context) => IconButton.filledTonal(
icon: const Icon(Icons.menu_rounded),
onPressed: () => Scaffold.of(context).openDrawer(),
tooltip: MaterialLocalizations.of(context)
.openAppDrawerTooltip,
),
),
actions: isSearching
? [
IconButton(
icon: Icon(
Icons.cancel,
color: Theme.of(context).colorScheme.onSurface,
),
onPressed: () => setState(() {
textEditingController.clear();
searchQuery = null;
}),
tooltip: AppLocalizations.of(context)!.clear,
)
]
: [
SortOrderButton(
tabs.elementAt(_tabController!.index),
),
SortByMenuButton(
tabs.elementAt(_tabController!.index),
),
IconButton(
icon: finampSettings.isFavourite
? const Icon(Icons.favorite_rounded)
: const Icon(Icons.favorite_outline_rounded),
onPressed: finampSettings.isOffline
? null
: () => FinampSettingsHelper.setIsFavourite(
!finampSettings.isFavourite),
tooltip: AppLocalizations.of(context)!.favourites,
),
IconButton(
icon: const Icon(Icons.search_rounded),
onPressed: () => setState(() {
isSearching = true;
}),
tooltip: MaterialLocalizations.of(context)
.searchFieldLabel,
),
],
),
bottomNavigationBar: const NowPlayingBar(),
drawer: const MusicScreenDrawer(),
floatingActionButton: Padding(
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);
}
},
),
_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(),
),
),
),
],
),
),
),
),
);
},
);
},
);
}
}
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),
),
),
);
}
}