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: 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(), ), 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), ), ), 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(), ), ), ), ), ], ), ), ], ), ), ); }, ); }, ); } } 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, 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(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, ), ), 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: [ 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(width: 12), _HeroChip( icon: isFavourite ? Icons.favorite_rounded : Icons.album_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.surfaceVariant.withOpacity(0.58), borderRadius: BorderRadius.circular(999), border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.55)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, color: colorScheme.secondary, size: 18), const SizedBox(width: 8), Text( label, style: TextStyle( color: colorScheme.onSurface, 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.surfaceVariant.withOpacity(0.50), borderRadius: BorderRadius.circular(999), border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.55)), ), 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), gradient: LinearGradient( colors: [colorScheme.primary, colorScheme.secondary], ), ), 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), ), ), ); } }