Pato05
2862c9ec05
restore translations functionality make scrollViews handle mouse pointers like touch, so that pull to refresh functionality is available exit app if opening cache or settings fails (another instance running) remove draggable_scrollbar and use builtin widget instead fix email login better way to manage lyrics (less updates and lookups in the lyrics List) fix player_screen on mobile (too big -> just average :)) right click: use TapUp events instead desktop: show context menu on triple dots button also avoid showing connection error if the homepage is cached and available offline i'm probably forgetting something idk
431 lines
13 KiB
Dart
431 lines
13 KiB
Dart
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.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/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) {
|
|
return 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.ROW:
|
|
default:
|
|
return HomepageRowSection(section);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (_error) return const ErrorScreen();
|
|
List<HomePageSection>? sections;
|
|
if (_homePage != null) {
|
|
sections = _homePage!.sections;
|
|
}
|
|
|
|
final actualScrollConfiguration = ScrollConfiguration.of(context);
|
|
return ScrollConfiguration(
|
|
behavior: actualScrollConfiguration.copyWith(
|
|
dragDevices: {
|
|
PointerDeviceKind.mouse,
|
|
PointerDeviceKind.stylus,
|
|
PointerDeviceKind.touch,
|
|
PointerDeviceKind.trackpad
|
|
},
|
|
),
|
|
child: 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;
|
|
const HomePageItemWidget(this.item, {super.key});
|
|
|
|
@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.SHOW:
|
|
return ShowCard(
|
|
item.value,
|
|
onTap: () {
|
|
Navigator.of(context)
|
|
.pushRoute(builder: (context) => ShowScreen(item.value));
|
|
},
|
|
);
|
|
default:
|
|
return const SizedBox(height: 0, width: 0);
|
|
}
|
|
}
|
|
}
|