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( 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: [ 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 createState() => _HomePageWidgetState(); } class _HomePageWidgetState extends State { HomePage? _homePage; bool _error = false; bool _loadExplicitlyRequested = false; final _indicatorKey = GlobalKey(); Future _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 _loadLocalChannel() async { final HomePage? homePage = await HomePage.local(widget.channel?.target ?? ''); //print('LOCAL: ${_hp.sections}'); if (homePage != null) setState(() => _homePage = homePage); } Future _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? 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 createState() => _HomepageRowSectionState(); } class _HomepageRowSectionState extends State { 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); } } }