freezer/lib/ui/home_screen.dart
Pato05 4b5d0bd09c
improve player screen with blurred album art
ui improvements in lyrics screen
animated bars when track is playing
fix back button when player screen is open
instantly pop when track is changed in queue list
2024-04-29 16:23:22 +02:00

482 lines
15 KiB
Dart

import 'package:collection/collection.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:freezer/api/deezer.dart';
import 'package:freezer/api/definitions.dart';
import 'package:freezer/api/player/audio_handler.dart';
import 'package:freezer/main.dart';
import 'package:freezer/ui/error.dart';
import 'package:freezer/ui/external_link_route.dart';
import 'package:freezer/ui/menu.dart';
import 'package:freezer/translations.i18n.dart';
import 'tiles.dart';
import 'details_screens.dart';
class _SearchHeaderDelegate extends SliverPersistentHeaderDelegate {
@override
double get maxExtent => 76.0;
@override
double get minExtent => 76.0;
@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) =>
false;
@override
Widget build(
BuildContext context, double shrinkOffset, bool overlapsContent) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(56.0)),
child: ListTile(
mouseCursor: MaterialStateMouseCursor.textable,
leading: const Icon(Icons.search),
title: Text('Search or paste URL'.i18n),
onTap: () => Navigator.of(context).pushNamed('/search'))));
}
}
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
final actualScrollConfiguration = ScrollConfiguration.of(context);
return ScrollConfiguration(
behavior: actualScrollConfiguration.copyWith(
dragDevices: {
PointerDeviceKind.mouse,
PointerDeviceKind.stylus,
PointerDeviceKind.touch,
PointerDeviceKind.trackpad
},
),
child: AnnotatedRegion<SystemUiOverlayStyle>(
value: Theme.of(context).appBarTheme.systemOverlayStyle ??
const SystemUiOverlayStyle(),
child: SafeArea(
child: Scaffold(
body: NestedScrollView(
floatHeaderSlivers: true,
headerSliverBuilder: (context, _) => [
SliverPersistentHeader(
delegate: _SearchHeaderDelegate(), floating: true)
],
body: const HomePageWidget(cacheable: true),
),
),
),
),
);
}
}
class FreezerTitle extends StatelessWidget {
const FreezerTitle({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(0, 24, 0, 8),
child: LayoutBuilder(builder: (context, constraints) {
return Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Image.asset('assets/icon.png', width: 64, height: 64),
const Text(
'freezer',
style: TextStyle(fontSize: 56, fontWeight: FontWeight.w900),
)
],
);
}),
);
}
}
class HomePageScreen extends StatelessWidget {
final String title;
final HomePage? homePage;
final bool cacheable;
final DeezerChannel channel;
const HomePageScreen({
super.key,
required this.title,
required this.channel,
this.homePage,
this.cacheable = false,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(title)),
body: HomePageWidget(
homePage: homePage,
channel: channel,
cacheable: cacheable,
),
);
}
}
class HomePageWidget extends StatefulWidget {
final HomePage? homePage;
final bool cacheable;
final DeezerChannel? channel;
const HomePageWidget(
{this.homePage, this.channel, this.cacheable = false, super.key});
@override
State<HomePageWidget> createState() => _HomePageWidgetState();
}
class _HomePageWidgetState extends State<HomePageWidget> {
HomePage? _homePage;
bool _error = false;
bool _loadExplicitlyRequested = false;
final _indicatorKey = GlobalKey<RefreshIndicatorState>();
Future<void> _loadChannel() async {
HomePage? homePage;
//Fetch channel from api
try {
if (widget.channel == null) {
homePage = await DeezerAPI.instance.homePage();
} else {
homePage = await DeezerAPI.instance.getChannel(widget.channel!.target);
}
} catch (e) {
homePage = null;
}
if (!mounted) return;
if (homePage == null) {
if (_homePage == null) {
//On error
setState(() => _error = true);
}
return;
}
if (homePage.sections.isEmpty) return;
if (widget.cacheable) homePage.save(widget.channel?.target ?? '');
setState(() => _homePage = homePage!);
}
Future<void> _loadLocalChannel() async {
final HomePage? homePage =
await HomePage.local(widget.channel?.target ?? '');
//print('LOCAL: ${_hp.sections}');
if (homePage != null) setState(() => _homePage = homePage);
}
Future<void> _load() async {
if (widget.homePage != null && widget.homePage!.sections.isNotEmpty) {
setState(() => _homePage = widget.homePage);
return;
}
if (!_loadExplicitlyRequested) {
if (_homePage == null && widget.cacheable) await _loadLocalChannel();
if (_homePage == null ||
DateTime.now().difference(_homePage!.lastUpdated) >
HomePage.cacheDuration) await _loadChannel();
_loadExplicitlyRequested = true;
return;
}
await _loadChannel();
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_indicatorKey.currentState!.show();
});
}
Widget getSectionChild(HomePageSection section) {
switch (section.layout) {
case HomePageSectionLayout.grid:
return HomePageGridSection(section);
case HomePageSectionLayout.slideshow:
case HomePageSectionLayout.row:
case HomePageSectionLayout.horizontalList:
return HomepageRowSection(section);
}
}
@override
Widget build(BuildContext context) {
if (_error) return const ErrorScreen();
List<HomePageSection>? sections;
if (_homePage != null) {
sections = _homePage!.sections;
}
return RefreshIndicator(
key: _indicatorKey,
onRefresh: _load,
child: _homePage == null
? const SizedBox.expand(child: SingleChildScrollView())
: ListView.builder(
itemBuilder: (context, index) {
return Padding(
padding: index == 0
? EdgeInsets.zero
: const EdgeInsets.only(top: 16.0),
child: getSectionChild(sections![index]));
},
itemCount: sections!.length,
padding: const EdgeInsets.symmetric(horizontal: 8.0),
),
);
}
}
class HomepageRowSection extends StatelessWidget {
final HomePageSection section;
const HomepageRowSection(this.section, {super.key});
Widget buildChild(BuildContext context, List<HomePageItem> items,
{bool hasMore = false}) {
return Row(children: [
...items.map((item) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0),
child: HomePageItemWidget(item),
)),
if (hasMore)
TextButton(
onPressed: () => Navigator.of(context).pushRoute(
builder: (context) => HomePageScreen(
title: section.title!,
channel: DeezerChannel(target: section.pagePath),
),
),
child: Text('Show more'.i18n))
]);
}
List<List<T>> _sliceInNLists<T>(List<T> source, int n) {
final List<List<T>> dest = List.generate(n, (_) => [], growable: false);
int i = 0;
for (var item in source) {
dest[i].add(item);
if (++i == n) i = 0;
}
return dest;
}
@override
Widget build(BuildContext context) {
final Widget child = switch (section.layout) {
HomePageSectionLayout.horizontalList => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: _sliceInNLists(section.items!, 3)
.map((e) => buildChild(context, e))
.toList(growable: false)),
_ =>
buildChild(context, section.items!, hasMore: section.hasMore ?? false)
};
return ListTile(
title: InkWell(
onTap: section.hasMore == true
? () => Navigator.of(context).pushRoute(
builder: (context) => HomePageScreen(
title: section.title!,
channel: DeezerChannel(target: section.pagePath),
),
)
: null,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
section.title ?? '',
textAlign: TextAlign.left,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style:
const TextStyle(fontSize: 20.0, fontWeight: FontWeight.w900),
)),
if (section.hasMore == true) ...[
const SizedBox(width: 16.0),
const Icon(Icons.keyboard_arrow_right),
],
],
),
),
subtitle: Scrollbar(
thickness: MainScreen.of(context).isDesktop ? null : 1.0,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: child,
),
),
);
}
}
class HomePageGridSection extends StatelessWidget {
final HomePageSection section;
final double horizontalSpacing;
final double verticalSpacing;
const HomePageGridSection(
this.section, {
this.horizontalSpacing = 6.0,
this.verticalSpacing = 6.0,
super.key,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (section.title != null)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 6.0),
child: Text(
section.title!,
textAlign: TextAlign.left,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style:
const TextStyle(fontSize: 20.0, fontWeight: FontWeight.w900),
),
),
LayoutBuilder(builder: (context, constraints) {
final amountThatCanFit =
constraints.maxWidth ~/ (150.0 + horizontalSpacing);
final widthOfEach =
constraints.maxWidth / amountThatCanFit - horizontalSpacing;
return Wrap(
spacing: horizontalSpacing,
runSpacing: verticalSpacing,
alignment: WrapAlignment.start,
children: section.items!
.map((e) => SizedBox(
width: widthOfEach,
child: HomePageItemWidget(e),
))
.toList(growable: false));
}),
],
);
}
}
class HomePageItemWidget extends StatelessWidget {
final HomePageItem item;
final Size? itemSize;
const HomePageItemWidget(this.item, {super.key, this.itemSize});
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
switch (item.type) {
case HomePageItemType.SMARTTRACKLIST:
return SmartTrackListTile(
item.value,
size: (item.value as SmartTrackList).id == 'flow' ? 96.0 : 128.0,
onTap: () => playerHelper.playFromSmartTrackList(item.value),
);
case HomePageItemType.ALBUM:
return AlbumCard(
item.value,
onTap: () {
Navigator.of(context)
.pushRoute(builder: (context) => AlbumDetails(item.value));
},
onSecondary: (details) {
MenuSheet m = MenuSheet(context);
m.defaultAlbumMenu(item.value, details: details);
},
);
case HomePageItemType.ARTIST:
return ArtistTile(
item.value,
onTap: () {
Navigator.of(context)
.pushRoute(builder: (context) => ArtistDetails(item.value));
},
onSecondary: (details) {
MenuSheet m = MenuSheet(context);
m.defaultArtistMenu(item.value, details: details);
},
);
case HomePageItemType.PLAYLIST:
return PlaylistCardTile(
item.value,
onTap: () {
Navigator.of(context)
.pushRoute(builder: (context) => PlaylistDetails(item.value));
},
onSecondary: (details) {
MenuSheet m = MenuSheet(context);
m.defaultPlaylistMenu(item.value, details: details);
},
);
case HomePageItemType.CHANNEL:
return ChannelTile(
item.value,
onTap: () {
Navigator.of(context).pushRoute(
builder: (context) => HomePageScreen(
channel: item.value,
title: item.value.title.toString(),
),
);
},
);
case HomePageItemType.EXTERNAL_LINK:
return ChannelTile(
item.value,
onTap: () {
final channel = item.value as DeezerChannel;
Navigator.of(context).pushRoute(
builder: (context) => ExternalLinkRoute(
target: channel.target!,
title: channel.title ?? '',
),
);
},
);
case HomePageItemType.SHOW:
return ShowCard(
item.value,
onTap: () {
Navigator.of(context)
.pushRoute(builder: (context) => ShowScreen(item.value));
},
);
case HomePageItemType.TRACK:
final track = item.value as Track;
return TrackCardTile.fromTrack(
track,
onTap: () => playerHelper.playSearchMixDeferred(track),
onSecondary: (details) =>
MenuSheet(context).defaultTrackMenu(track, details: details),
width:
mediaQuery.size.width > 530 ? null : mediaQuery.size.width * 0.75,
);
default:
return const SizedBox(height: 0, width: 0);
}
}
}