600 lines
21 KiB
Dart
600 lines
21 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: 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<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.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),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|