452 lines
14 KiB
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));
|
|
},
|
|
onHold: () {
|
|
MenuSheet m = MenuSheet(context);
|
|
m.defaultAlbumMenu(item.value);
|
|
},
|
|
);
|
|
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);
|
|
}
|
|
}
|
|
}
|