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 createState() => _MusicScreenState(); } class _MusicScreenState extends State with TickerProviderStateMixin { bool isSearching = false; bool _showShuffleFab = false; TextEditingController textEditingController = TextEditingController(); String? searchQuery; final _musicScreenLogger = Logger("MusicScreen"); TabController? _tabController; final _audioServiceHelper = GetIt.instance(); final _finampUserHelper = GetIt.instance(); final _jellyfinApiHelper = GetIt.instance(); 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>( valueListenable: _finampUserHelper.finampUsersListenable, builder: (context, value, _) { return ValueListenableBuilder>( 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 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), ), ), ); } }