freezer/lib/ui/home_screen.dart

452 lines
14 KiB
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.homePage();
} else {
homePage = await deezerAPI.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:
default:
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 StatefulWidget {
final HomePageSection section;
const HomepageRowSection(this.section, {super.key});
@override
State<HomepageRowSection> createState() => _HomepageRowSectionState();
}
class _HomepageRowSectionState extends State<HomepageRowSection> {
final _controller = ScrollController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(
widget.section.title ?? '',
textAlign: TextAlign.left,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 20.0, fontWeight: FontWeight.w900),
),
subtitle: Scrollbar(
controller: _controller,
thickness: MainScreen.of(context).isDesktop ? null : 1.0,
child: SingleChildScrollView(
controller: _controller,
scrollDirection: Axis.horizontal,
child: Row(
children: List.generate(widget.section.items!.length + 1, (j) {
//Has more items
if (j == widget.section.items!.length) {
if (widget.section.hasMore ?? false) {
return TextButton(
child: Text(
'Show more'.i18n,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 20.0),
),
onPressed: () => Navigator.of(context).pushRoute(
builder: (context) => HomePageScreen(
title: widget.section.title!,
channel:
DeezerChannel(target: widget.section.pagePath),
),
),
);
}
return const SizedBox();
}
//Show item
HomePageItem item = widget.section.items![j];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0),
child: HomePageItemWidget(item),
);
}),
),
),
));
}
}
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) {
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));
},
);
default:
return const SizedBox(height: 0, width: 0);
}
}
}