Pato05
4b5d0bd09c
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
482 lines
15 KiB
Dart
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);
|
|
}
|
|
}
|
|
}
|