diff --git a/zune_ui/lib/main.dart b/zune_ui/lib/main.dart index f580dee..d6a0292 100644 --- a/zune_ui/lib/main.dart +++ b/zune_ui/lib/main.dart @@ -6,6 +6,7 @@ import 'package:zune_ui/pages/overlays_page/index.dart'; import 'package:zune_ui/pages/player_page/index.dart'; import 'package:zune_ui/providers/global_state/index.dart'; import 'package:zune_ui/providers/scroll_state/scroll_state.dart'; +import 'package:zune_ui/providers/animation_provider/index.dart'; import 'package:zune_ui/pages/home_page/page.dart'; import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:go_router/go_router.dart'; @@ -90,23 +91,25 @@ class MyApp extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { - return SizedBox( - width: initialSize.width, - height: initialSize.height, - child: Stack( - alignment: Alignment.topCenter, - children: [ - WidgetsApp.router( - debugShowCheckedModeBanner: false, - routerConfig: _router, - color: const Color.fromARGB(255, 0, 0, 0), - textStyle: const TextStyle( - // Classic Zune Font :) - fontFamily: 'Zegoe UI', + return MusicPlayerAnimationProvider( + child: SizedBox( + width: initialSize.width, + height: initialSize.height, + child: Stack( + alignment: Alignment.topCenter, + children: [ + WidgetsApp.router( + debugShowCheckedModeBanner: false, + routerConfig: _router, + color: const Color.fromARGB(255, 0, 0, 0), + textStyle: const TextStyle( + // Classic Zune Font :) + fontFamily: 'Zegoe UI', + ), ), - ), - WindowBar(router: _router), - ], + WindowBar(router: _router), + ], + ), ), ); } diff --git a/zune_ui/lib/pages/music_page/album_grid/album_grid_tile.dart b/zune_ui/lib/pages/music_page/album_grid/album_grid_tile.dart index 65316bf..43175fb 100644 --- a/zune_ui/lib/pages/music_page/album_grid/album_grid_tile.dart +++ b/zune_ui/lib/pages/music_page/album_grid/album_grid_tile.dart @@ -7,7 +7,8 @@ const SCALE_VALUE = 3; const INVERSE_SCALE_VALUE = 1 / SCALE_VALUE; class AlbumsGridTile extends StatelessWidget { - final GlobalKey _globalKey = GlobalKey(); + final GlobalKey _transformedTextKey = GlobalKey(); + final GlobalKey _albumTileKey = GlobalKey(); final AlbumGridTileGroup albumGroup; @@ -35,38 +36,54 @@ class AlbumsGridTile extends StatelessWidget { final albumCover = album.album_cover; final albumName = album.album_name; - return Stack( - children: [ - SquareTile( - size: TileUtility.regularTileWidth, - alignment: Alignment.bottomRight, - textStyle: Styles.albumTileFont, - background: albumCover, - text: albumCover != null ? null : albumName.toUpperCase(), - ), - Transform( - transform: Matrix4.identity() - // Honestly this is best I could come up with. - ..scale(INVERSE_SCALE_VALUE) - // Computing with SCALE_VALUE * .95 to closely match spacing as in Zune UI - ..translate(TileUtility.regularTileWidth * SCALE_VALUE * .95, - TileUtility.regularTileWidth, 0.0), - child: OverflowBox( - alignment: Alignment.center, - maxHeight: TileUtility.regularTileWidth * SCALE_VALUE, - child: Flow( - delegate: ParallaxFlowDelegate( - scrollable: Scrollable.of(context), - itemContext: context, - itemKey: _globalKey, + onAlbumTapHandler() { + final renderBox = + _albumTileKey.currentContext?.findRenderObject() as RenderBox?; + + // if (renderBox != null) { + // final widgetPosition = renderBox.localToGlobal(Offset.zero); + // context.go(ApplicationRoute.albums.route, extra: widgetPosition); + // } + + //TBI: Implement album tap handler + } + + return GestureDetector( + key: _albumTileKey, + onTap: onAlbumTapHandler, + child: Stack( + children: [ + SquareTile( + size: TileUtility.regularTileWidth, + alignment: Alignment.bottomRight, + textStyle: Styles.albumTileFont, + background: albumCover, + text: albumCover != null ? null : albumName.toUpperCase(), + ), + Transform( + transform: Matrix4.identity() + // Honestly this is best I could come up with. + ..scale(INVERSE_SCALE_VALUE) + // Computing with SCALE_VALUE * .95 to closely match spacing as in Zune UI + ..translate(TileUtility.regularTileWidth * SCALE_VALUE * .95, + TileUtility.regularTileWidth, 0.0), + child: OverflowBox( + alignment: Alignment.center, + maxHeight: TileUtility.regularTileWidth * SCALE_VALUE, + child: Flow( + delegate: ParallaxBackgroundFlowDelegate( + scrollable: Scrollable.of(context), + itemContext: context, + itemKey: _transformedTextKey, + ), + children: [ + Text(key: _transformedTextKey, albumName), + ], ), - children: [ - Text(key: _globalKey, albumName), - ], ), ), - ), - ], + ], + ), ); } @@ -83,12 +100,12 @@ class AlbumsGridTile extends StatelessWidget { /// NOTE: This code is taken & slightly modified from: /// -> https://docs.flutter.dev/cookbook/effects/parallax-scrolling /// -class ParallaxFlowDelegate extends FlowDelegate { +class ParallaxBackgroundFlowDelegate extends FlowDelegate { final ScrollableState scrollable; final BuildContext itemContext; final GlobalKey itemKey; - ParallaxFlowDelegate({ + ParallaxBackgroundFlowDelegate({ required this.scrollable, required this.itemContext, required this.itemKey, @@ -160,7 +177,7 @@ class ParallaxFlowDelegate extends FlowDelegate { } @override - bool shouldRepaint(ParallaxFlowDelegate oldDelegate) { + bool shouldRepaint(ParallaxBackgroundFlowDelegate oldDelegate) { return scrollable != oldDelegate.scrollable || itemContext != oldDelegate.itemContext || itemKey != oldDelegate.itemKey; diff --git a/zune_ui/lib/pages/music_page/album_list/album_list.dart b/zune_ui/lib/pages/music_page/album_list/album_list.dart new file mode 100644 index 0000000..1797623 --- /dev/null +++ b/zune_ui/lib/pages/music_page/album_list/album_list.dart @@ -0,0 +1,99 @@ +part of album_list_widget; + +typedef AlbumGroupMap = LinkedHashMap>; + +class AlbumList extends StatefulWidget { + const AlbumList({ + super.key, + }); + + @override + State createState() => _AlbumListState(); +} + +class _AlbumListState extends State { + // void _onReturnTapHandler() { + // console.log("Should go back to album Grid"); + // final musicPlayerAnimationContext = + // parent.MusicPlayerAnimationProvider.of(context); + + // musicPlayerAnimationContext?.executeWith(() async { + // if (context.mounted) { + // // context.go(ApplicationRoute.home.route); + // console.log("Should go back to album Grid"); + // } + // }); + // } + + /// NOTE: Album List view allows "jumping" to a specific album via + /// group keys rendered in the list. + /// + /// This method is responsible for generating search index configuration map + /// which represents a group key e.g. letter "a" mapped to a function that + /// animated scroll controller to a location of the group key in the list. + /// + /// Using pre-defined constant values to derive the group collection & key heights + /// because the list view wrapper is dynamically built. This might not be the best + /// solution to derive the search index configuration, perhaps a global solution + /// might be faster. + void _generateSearchIndexConfiguration( + ScrollController? scrollController, AlbumGroupMap albumGroupMap) { + // SearchIndexConfig is by default an empty map + if (scrollController == null) return; + double offset = 0.0; + SearchIndexConfig searchIndexConfiguration = {}; + + for (final entry in albumGroupMap.entries) { + // Since offset is a mutable closure, need to define a final value here + final currentOffset = offset; + searchIndexConfiguration.putIfAbsent( + entry.key, + () => () async => await scrollController.animateTo( + currentOffset, + duration: const Duration(milliseconds: 250), + curve: Curves.ease, + ), + ); + // Derive group collection & key heights to compute offsets needed for animation + final groupCollectionHeight = entry.value.fold( + offset, (acc, album) => acc + ALBUM_LIST_TILE_SIZE + ALBUM_LIST_GAP); + const groupKeyHeight = ALBUM_LIST_TILE_SIZE + ALBUM_LIST_GAP; + + offset = groupCollectionHeight + groupKeyHeight; + } + + OverlaysProvider.of(context)?.setSearchTileConfig(searchIndexConfiguration); + } + + List _generateAlbumGroups( + UnmodifiableListView albums, { + ScrollController? scrollController, + }) { + AlbumGroupMap albumGroupMap = parent.generateItemMap( + albums, + (e) => parent.generateItemGroupKey(e.album_name), + ); + + // Generate search index configuration needed for "jumping" to a specific album via group keys + _generateSearchIndexConfiguration(scrollController, albumGroupMap); + + return parent.generateItemListFromMap( + albumGroupMap, + (groupKey, item) => (groupKey: groupKey, album: item), + ); + } + + @override + Widget build(BuildContext context) { + /// NOT RIGHT, the menu wrapper needs to be around music categories when this happens..... + /// Here you need to lerp from album location in the grid to location in the list + return ListWrapper, AlbumSummary, + AlbumListTileGroup>( + selector: (state) => state.allAlbums, + listGap: ALBUM_LIST_GAP, + itemBuilder: (context, albumGroup) => + AlbumListTile(albumGroup: albumGroup), + itemsMiddleware: _generateAlbumGroups, + ); + } +} diff --git a/zune_ui/lib/pages/music_page/album_list/album_list_tile.dart b/zune_ui/lib/pages/music_page/album_list/album_list_tile.dart new file mode 100644 index 0000000..6653035 --- /dev/null +++ b/zune_ui/lib/pages/music_page/album_list/album_list_tile.dart @@ -0,0 +1,177 @@ +part of album_list_widget; + +typedef AlbumListTileGroup = ({String? groupKey, AlbumSummary? album}); + +/// NOTE: Helper cache to store the wrapping state of the album title. +/// LRU Cache with a max size of 1000 entries. +final LRUCache _titleWrapCache = + LRUCache(maxSize: 1000); + +class AlbumListTile extends StatelessWidget { + final AlbumListTileGroup albumGroup; + + const AlbumListTile({ + super.key, + required this.albumGroup, + }); + + static bool isTextWrapping( + String text, + TextStyle style, + int maxLines, + double maxWidth, + ) { + // Create cache key: title + final cacheKey = text; + + // Check cache first + if (_titleWrapCache.containsKey(cacheKey)) { + return _titleWrapCache[cacheKey]!; + } + // If not in cache, measure text and store result + final textPainter = TextPainter( + text: TextSpan( + text: text, + style: style, + ), + textDirection: TextDirection.ltr, + maxLines: maxLines, + ); + + textPainter.layout(maxWidth: maxWidth); + + final singleLineHeight = style.fontSize! * style.height!; + final didWrap = textPainter.height > singleLineHeight; + + textPainter.dispose(); + + _titleWrapCache[cacheKey] = didWrap; + return _titleWrapCache[cacheKey]!; + } + + /// NOTE: Because the album title can sometimes wrap, need to regenerate the y offset + /// for the album artist to ensure it is positioned correctly following + /// the zune experience. + /// + /// Basically, the album title pushes down on artist name which is not wrapping + /// and the artist name is pushed down accordingly. + static ParallaxConfiguration adjustYOffsetForWrapping( + bool isWrapping, ParallaxConfiguration config) { + return ( + x: config.x, + y: isWrapping + ? config.y + Styles.albumTitleFont.fontSize!.toDouble() + : config.y, + velocity: config.velocity, + signedDirection: config.signedDirection, + constraints: config.constraints, + ); + } + + Widget generateGroupKeyTile(BuildContext context, String groupKey) { + /// NOTE: This provider exposes all of the overlays in the app. + final overlaysProvider = OverlaysProvider.of(context); + return SizedBox( + child: Align( + alignment: Alignment.centerLeft, + child: GestureDetector( + onTap: () => overlaysProvider!.showOverlay(OverlayType.searchIndex), + child: SquareTile( + size: ALBUM_LIST_TILE_SIZE, + alignment: Alignment.bottomRight, + noBorder: true, + child: SquareTile( + size: TileUtility.smallTileWidth, + alignment: Alignment.bottomRight, + textStyle: Styles.searchTileFont, + text: albumGroup.groupKey!, + ), + ), + ), + ), + ); + } + + Widget generateAlbumTile(BuildContext context, AlbumSummary album) { + final isAlbumNameWrapping = isTextWrapping( + album.album_name, Styles.albumTitleFont, 2, REMAINING_WIDTH); + return ListItemWrapper( + data: album, + height: ALBUM_LIST_TILE_SIZE, + widgetConfigs: [ + // Album Cover + ( + builder: (context, album) => Consumer( + builder: (context, state, child) => GestureDetector( + onTap: () { + state.updateCurrentlyPlaying(album); + context.push(ApplicationRoute.player.route); + }, + child: Container( + // NOTE: This is to ensure the album cover is centered vertically. + alignment: Alignment.centerLeft, + child: SquareTile( + size: ALBUM_LIST_TILE_SIZE, + alignment: Alignment.bottomRight, + textStyle: Styles.searchTileFont + .copyWith(fontWeight: FontWeight.w100), + background: album.album_cover, + text: album.album_cover != null + ? null + : album.album_name.toUpperCase(), + ), + ), + ), + ), + parallaxConfig: ALBUM_LIST_PARALLAX_CONFIG[0]!, + ), + // Play Button + ( + builder: (context, _) => const ListTilePlayButton(), + parallaxConfig: ALBUM_LIST_PARALLAX_CONFIG[1]! + ), + // Albums Title + ( + builder: (context, album) => Text( + album.album_name.toUpperCase(), + style: Styles.albumTitleFont, + // NOTE: This is to ensure the album title is wrapped if it is wrapping. + maxLines: isAlbumNameWrapping ? 2 : 1, + overflow: TextOverflow.visible, + ), + parallaxConfig: ALBUM_LIST_PARALLAX_CONFIG[2]! + ), + // Albums Artist + ( + builder: (context, album) => Text( + album.artist_name.toUpperCase(), + overflow: TextOverflow.ellipsis, + style: Styles.albumArtistFont, + ), + parallaxConfig: adjustYOffsetForWrapping( + isAlbumNameWrapping, + ALBUM_LIST_PARALLAX_CONFIG[3]!, + ) + ), + // Albums Songs + ( + builder: (context, album) => + LazyAlbumTracksList(track_ids: album.track_ids), + parallaxConfig: adjustYOffsetForWrapping( + isAlbumNameWrapping, + ALBUM_LIST_PARALLAX_CONFIG[4]!, + ) + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return albumGroup.groupKey != null + ? generateGroupKeyTile(context, albumGroup.groupKey!) + : albumGroup.album != null + ? generateAlbumTile(context, albumGroup.album!) + : const SizedBox.shrink(); + } +} diff --git a/zune_ui/lib/pages/music_page/album_list/album_track_list.dart b/zune_ui/lib/pages/music_page/album_list/album_track_list.dart new file mode 100644 index 0000000..9a00b5e --- /dev/null +++ b/zune_ui/lib/pages/music_page/album_list/album_track_list.dart @@ -0,0 +1,63 @@ +part of album_list_widget; + +/// NOTE: Added this value based on "vibes" as close as I see on Zune display. +const ROW_SIZE = 70.0; +const ROW_GAP = 4.0; + +class AlbumTracksList extends StatelessWidget { + final UnmodifiableListView tracks; + const AlbumTracksList({ + super.key, + required this.tracks, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: ROW_SIZE, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: ListView.separated( + physics: const NeverScrollableScrollPhysics(), + scrollDirection: Axis.vertical, + itemCount: tracks.length, + separatorBuilder: (context, index) => const SizedBox( + height: ROW_GAP, + ), + itemBuilder: (context, index) => Text( + /// NOTE: Zune has tracks in default case. + tracks[index].track_name, + overflow: TextOverflow.ellipsis, + style: Styles.albumTrackFont, + ), + ), + ), + ); + } +} + +class LazyAlbumTracksList extends StatelessWidget { + final List track_ids; + const LazyAlbumTracksList({ + super.key, + required this.track_ids, + }); + + @override + Widget build(BuildContext context) { + final globalState = context.read(); + return FutureBuilder>( + future: globalState.getTracksFromIds(track_ids), + builder: (context, snapshot) { + final connectionIsDone = + snapshot.connectionState == ConnectionState.done; + final data = snapshot.data; + final dataIsPresent = data != null && data.isNotEmpty; + final readyToRender = connectionIsDone && dataIsPresent; + return readyToRender + ? AlbumTracksList(tracks: data) + : const SizedBox.shrink(); + }, + ); + } +} diff --git a/zune_ui/lib/pages/music_page/album_list/constants.dart b/zune_ui/lib/pages/music_page/album_list/constants.dart new file mode 100644 index 0000000..cd959a7 --- /dev/null +++ b/zune_ui/lib/pages/music_page/album_list/constants.dart @@ -0,0 +1,68 @@ +part of album_list_widget; + +const ALBUM_LIST_GAP = 10.0; + +/// TODO: Because I am viewing this with the MusicCategories on 2 - lines, +/// it should show at most 3 albums including with the group key tile. +const ALBUM_LIST_TILE_SIZE = 128.0; + +/// NOTE: Remaining width is the width of the album list tile minus the album cover size and padding. +const REMAINING_WIDTH = 272.0 - ALBUM_LIST_TILE_SIZE - 16.0; + +const Map ALBUM_LIST_PARALLAX_CONFIG = { + /// Album Cover + 0: ( + x: 0, + y: 0, // TODO: Change this to the actual y position of the album cover + + velocity: 2 * 2, + signedDirection: -1, + constraints: null, + ), + + /// Play Button + 1: ( + x: ALBUM_LIST_TILE_SIZE - PLAY_BUTTON_SIZE - 4.0 /* Padding */, + y: 0, + + /// NOTE: Fine tuned based on "vibes" as close as I see on Zune display. + /// Velocity is largest here to show the "shift" effect when scrolling. + /// Signed direct is positive so that when scrolling down the play button + /// moves down more apparently. + velocity: 1 * 8, + signedDirection: 1, + constraints: null, + ), + + /// Albums Title + 2: ( + x: ALBUM_LIST_TILE_SIZE + 16.0 /* Padding */, + y: 0, + velocity: 1 * 4, + signedDirection: -1, + + /// NOTE: Adding this constraints override to allow text such as album title + /// to overflow the width of the parent container. + /// If constraints are not provided, set the width to the remaining width + /// of the album list tile. + constraints: BoxConstraints.tightFor(width: REMAINING_WIDTH), + ), + + /// Albums Artist + 3: ( + x: ALBUM_LIST_TILE_SIZE + 16.0 /* Padding */, + y: 16.0 /* Font size for album name */ + 4.0 /* Padding */, + velocity: 2 * 4, + signedDirection: -1, + constraints: null, + ), + + /// Albums Songs + 4: ( + x: ALBUM_LIST_TILE_SIZE + 16.0 /* Padding */, + y: 16.0 /* Font size for album name */ + 4.0 /* Padding */ + 18.0, + velocity: 3 * 6, + signedDirection: -1, + constraints: null, + ), +}; diff --git a/zune_ui/lib/pages/music_page/album_list/font_styles.dart b/zune_ui/lib/pages/music_page/album_list/font_styles.dart new file mode 100644 index 0000000..bcb9393 --- /dev/null +++ b/zune_ui/lib/pages/music_page/album_list/font_styles.dart @@ -0,0 +1,33 @@ +part of album_list_widget; + +class Styles { + static const TextStyle searchTileFont = TextStyle( + fontWeight: FontWeight.w500, + fontSize: 24, + height: 1, + ); + static const TextStyle albumTileFont = TextStyle( + fontWeight: FontWeight.w300, + color: Colors.gray, + fontSize: 16, + height: 1, + ); + static const TextStyle albumTitleFont = TextStyle( + fontWeight: FontWeight.w700, + color: Colors.white, + fontSize: 16, + height: 1, + ); + static const TextStyle albumArtistFont = TextStyle( + fontWeight: FontWeight.w600, + color: Colors.gray, + fontSize: 12, + height: 1, + ); + static const TextStyle albumTrackFont = TextStyle( + fontWeight: FontWeight.w500, + color: Colors.gray, + fontSize: 8, + height: 1, + ); +} diff --git a/zune_ui/lib/pages/music_page/album_list/index.dart b/zune_ui/lib/pages/music_page/album_list/index.dart new file mode 100644 index 0000000..562714a --- /dev/null +++ b/zune_ui/lib/pages/music_page/album_list/index.dart @@ -0,0 +1,26 @@ +library album_list_widget; + +import 'dart:collection'; + +import 'package:flutter/widgets.dart'; +import 'package:zune_ui/database/index.dart'; +import 'package:provider/provider.dart'; +import 'package:go_router/go_router.dart'; +import 'package:zune_ui/pages/music_page/common/index.dart'; +import 'package:zune_ui/pages/overlays_page/index.dart'; +import 'package:zune_ui/pages/search_index_page/index.dart'; +import 'package:zune_ui/providers/global_state/index.dart'; +import 'package:zune_ui/widgets/common/index.dart'; +import 'package:zune_ui/utilities/index.dart'; + +/// NOTE: Scoping imports behind parent, so that console log & utils is exposed from Music Page +import 'package:zune_ui/pages/music_page/index.dart' as parent; +import 'package:zune_ui/widgets/custom/route_utils.dart'; + +part "album_list.dart"; +part "album_list_tile.dart"; +part "album_track_list.dart"; +part "font_styles.dart"; +part "constants.dart"; + +final console = parent.console; diff --git a/zune_ui/lib/pages/music_page/animation_provider.dart b/zune_ui/lib/pages/music_page/animation_provider.dart deleted file mode 100644 index 40a6f92..0000000 --- a/zune_ui/lib/pages/music_page/animation_provider.dart +++ /dev/null @@ -1,56 +0,0 @@ -part of music_page; - -enum EventType { - unmountEvent, - mountEvent, -} - -/// NOTE: This is provider responsible for managing animation -/// execution across multiple Music Player widgets. -/// -/// The key purpose is to schedule animation updates based -/// on the EventType and group Future-like calls together -/// to be executed when combined animation is required. -/// -/// Example: Unmounting event -/// Both AlbumGrid & MusicCategories widgets have their -/// respective unmount animation driven by AnimationControllers. -/// Each widget, registers an unmount event with a callback which -/// will perform a animation change such as forward/reverse. These -/// events will be executed in order* and followed by last call in -/// executeWith callback. -/// - -class MusicPlayerAnimationProvider extends InheritedWidget { - final Map Function()>> _map = {}; - - MusicPlayerAnimationProvider({ - Key? key, - required Widget child, - }) : super(key: key, child: child); - - static MusicPlayerAnimationProvider? of(BuildContext context) { - return context - .dependOnInheritedWidgetOfExactType(); - } - - void register(EventType key, Future Function() cb) { - if (_map.containsKey(key)) { - _map[key]?.add(cb); - } else { - _map[key] = [cb]; - } - } - - void executeWith(Future Function() finalAction) async { - for (final actions in _map.values) { - await Future.wait(actions.map((action) => action())); - } - await finalAction(); - } - - @override - bool updateShouldNotify(MusicPlayerAnimationProvider oldWidget) { - return true; - } -} diff --git a/zune_ui/lib/pages/music_page/artist_list/artist_list.dart b/zune_ui/lib/pages/music_page/artist_list/artist_list.dart index 056ac90..70a8c66 100644 --- a/zune_ui/lib/pages/music_page/artist_list/artist_list.dart +++ b/zune_ui/lib/pages/music_page/artist_list/artist_list.dart @@ -70,6 +70,7 @@ class _ArtistListState extends State { (e) => parent.generateItemGroupKey(e.artist_name), ); + // Generate search index configuration needed for "jumping" to a specific artist via group keys _generateSearchIndexConfiguration(scrollController, artistGroupMap); return parent.generateItemListFromMap( diff --git a/zune_ui/lib/pages/music_page/artist_list/constants.dart b/zune_ui/lib/pages/music_page/artist_list/constants.dart index 85d3554..be50e91 100644 --- a/zune_ui/lib/pages/music_page/artist_list/constants.dart +++ b/zune_ui/lib/pages/music_page/artist_list/constants.dart @@ -16,6 +16,7 @@ const Map ARTIST_PARALLAX_CONFIG = { /// moves down more apparently. velocity: 1 * 8, signedDirection: 1, + constraints: null, ), /// Artist Title @@ -29,6 +30,7 @@ const Map ARTIST_PARALLAX_CONFIG = { /// moves up making more space between title nad the album row. velocity: 2 * 2, signedDirection: -1, + constraints: null, ), /// Albums Row @@ -37,5 +39,6 @@ const Map ARTIST_PARALLAX_CONFIG = { y: 32, velocity: 3 * 4, signedDirection: -1, + constraints: null, ), }; diff --git a/zune_ui/lib/pages/music_page/common/list_tile_play_button.dart b/zune_ui/lib/pages/music_page/common/list_tile_play_button.dart index 7365528..abfe4af 100644 --- a/zune_ui/lib/pages/music_page/common/list_tile_play_button.dart +++ b/zune_ui/lib/pages/music_page/common/list_tile_play_button.dart @@ -1,5 +1,7 @@ part of music_common_widgets; +const double PLAY_BUTTON_SIZE = 36.0; + class ListTilePlayButton extends StatelessWidget { const ListTilePlayButton({ super.key, @@ -7,15 +9,29 @@ class ListTilePlayButton extends StatelessWidget { @override Widget build(BuildContext context) { - return const Align( + return Align( alignment: Alignment.centerLeft, - child: CircleWidget( - size: 36, - borderWidth: 2, - child: Icon( - Icons.play_arrow, - size: 28, - color: Colors.white, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.35), + blurRadius: 8, + offset: const Offset(0, 2), + spreadRadius: 1, + ), + ], + ), + child: // Play button on top + const CircleWidget( + size: PLAY_BUTTON_SIZE, + borderWidth: 2, + child: Icon( + Icons.play_arrow, + size: 28, + color: Colors.white, + ), ), ), ); diff --git a/zune_ui/lib/pages/music_page/common/list_tile_wrapper.dart b/zune_ui/lib/pages/music_page/common/list_tile_wrapper.dart index 4380084..c98ae33 100644 --- a/zune_ui/lib/pages/music_page/common/list_tile_wrapper.dart +++ b/zune_ui/lib/pages/music_page/common/list_tile_wrapper.dart @@ -7,6 +7,7 @@ typedef ParallaxConfiguration = ({ double y, double velocity, int signedDirection, + BoxConstraints? constraints, }); typedef WidgetConfig = ({ @@ -35,7 +36,7 @@ class ListItemWrapper extends StatelessWidget { /// NOTE: Need to remove clipping here because the play button /// during translation inside the Flow clipBehavior: Clip.none, - delegate: ParallaxFlowDelegate( + delegate: ParallaxListFlowDelegate( itemContext: context, scrollable: Scrollable.of(context), configuration: widgetConfigs @@ -54,12 +55,12 @@ class ListItemWrapper extends StatelessWidget { /// NOTE: This code is taken & slightly modified from: /// -> https://docs.flutter.dev/cookbook/effects/parallax-scrolling /// -class ParallaxFlowDelegate extends FlowDelegate { +class ParallaxListFlowDelegate extends FlowDelegate { final BuildContext itemContext; final ScrollableState scrollable; final Map configuration; - ParallaxFlowDelegate({ + ParallaxListFlowDelegate({ required this.scrollable, required this.itemContext, required this.configuration, @@ -67,11 +68,16 @@ class ParallaxFlowDelegate extends FlowDelegate { @override BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) { - /// NOTE: Adding this constraints override to allow text such as track name + /// NOTE: If the constraints are provided, use them. + /// Adding this constraints override to allow text such as track name /// to overflow the width of the parent container. - return BoxConstraints.tightFor( - width: constraints.maxWidth * 2, - ); + /// + /// If constraints are not provided, set the width to 2x the parent's width + /// to allow text such as track name to overflow the width of the parent container. + return configuration[i]?.constraints ?? + BoxConstraints.tightFor( + width: constraints.maxWidth * 2, + ); } @override @@ -115,7 +121,7 @@ class ParallaxFlowDelegate extends FlowDelegate { } @override - bool shouldRepaint(ParallaxFlowDelegate oldDelegate) { + bool shouldRepaint(ParallaxListFlowDelegate oldDelegate) { return scrollable != oldDelegate.scrollable || itemContext != oldDelegate.itemContext; } diff --git a/zune_ui/lib/pages/music_page/genre_list/genre_list_tile.dart b/zune_ui/lib/pages/music_page/genre_list/genre_list_tile.dart index 733789a..6205286 100644 --- a/zune_ui/lib/pages/music_page/genre_list/genre_list_tile.dart +++ b/zune_ui/lib/pages/music_page/genre_list/genre_list_tile.dart @@ -12,6 +12,7 @@ final Map parallaxConfig = { /// moves down more apparently. velocity: 1 * 8, signedDirection: 1, + constraints: null, ), /// Artist Title @@ -25,6 +26,7 @@ final Map parallaxConfig = { /// moves up making more space between title nad the album row. velocity: 2 * 2, signedDirection: -1, + constraints: null, ), /// Albums Row @@ -33,6 +35,7 @@ final Map parallaxConfig = { y: 32, velocity: 3 * 4, signedDirection: -1, + constraints: null, ), }; diff --git a/zune_ui/lib/pages/music_page/index.dart b/zune_ui/lib/pages/music_page/index.dart index 1d61fb7..e705ba9 100644 --- a/zune_ui/lib/pages/music_page/index.dart +++ b/zune_ui/lib/pages/music_page/index.dart @@ -9,9 +9,9 @@ import 'package:zune_ui/pages/music_page/view_selector/index.dart'; import 'package:zune_ui/widgets/common/index.dart'; import 'package:zune_ui/widgets/custom/debug_print.dart'; import 'package:zune_ui/widgets/custom/route_utils.dart'; +import 'package:zune_ui/providers/animation_provider/index.dart'; part "page.dart"; -part "animation_provider.dart"; part "utils.dart"; final console = DebugPrint().register(DebugComponent.musicPage); diff --git a/zune_ui/lib/pages/music_page/music_categories/index.dart b/zune_ui/lib/pages/music_page/music_categories/index.dart index e13cda1..bce0c65 100644 --- a/zune_ui/lib/pages/music_page/music_categories/index.dart +++ b/zune_ui/lib/pages/music_page/music_categories/index.dart @@ -7,6 +7,7 @@ import 'package:memoized/memoized.dart'; import 'package:provider/provider.dart'; import 'package:zune_ui/enums/index.dart'; import 'package:zune_ui/providers/global_state/index.dart'; +import 'package:zune_ui/providers/animation_provider/index.dart'; import 'package:zune_ui/widgets/common/index.dart'; /// NOTE: Scoping imports behind parent, so that console log is exposed from Music Page diff --git a/zune_ui/lib/pages/music_page/music_categories/music_categories_wrapper.dart b/zune_ui/lib/pages/music_page/music_categories/music_categories_wrapper.dart index 4edbcf2..b2353bb 100644 --- a/zune_ui/lib/pages/music_page/music_categories/music_categories_wrapper.dart +++ b/zune_ui/lib/pages/music_page/music_categories/music_categories_wrapper.dart @@ -11,6 +11,7 @@ class MusicCategoriesWrapper extends StatefulWidget { class _MusicCategoriesWrapperState extends State with SingleTickerProviderStateMixin { + bool _isDisposed = false; late final AnimationController _controller; late final Animation _opacityAnimation; @@ -73,6 +74,10 @@ class _MusicCategoriesWrapperState extends State @override void dispose() { + /// NOTE: Setting _isDisposed to true before controller is disposed + /// so that resource is still valid before running registered + /// animation sequence below. + _isDisposed = true; _controller.dispose(); super.dispose(); } @@ -81,11 +86,17 @@ class _MusicCategoriesWrapperState extends State WidgetsBinding.instance.addPostFrameCallback( (_) { final musicPlayerAnimationContext = - parent.MusicPlayerAnimationProvider.of(context); + MusicPlayerAnimationProvider.of(context); musicPlayerAnimationContext?.register( - parent.EventType.unmountEvent, - _controller.reverse, + EventType.unmountEvent, + () async { + /// NOTE: Check if the widget is not yet disposed before + /// attempting to reverse the animation controller. + if (!_isDisposed && mounted) { + await _controller.reverse(); + } + }, ); }, ); diff --git a/zune_ui/lib/pages/music_page/page.dart b/zune_ui/lib/pages/music_page/page.dart index 58e8b41..1a95117 100644 --- a/zune_ui/lib/pages/music_page/page.dart +++ b/zune_ui/lib/pages/music_page/page.dart @@ -63,11 +63,9 @@ class MusicPageWrapped extends StatelessWidget { @override Widget build(BuildContext context) { - return MusicPlayerAnimationProvider( - child: MusicPage( - size: size, - startingOffset: startingOffset, - ), + return MusicPage( + size: size, + startingOffset: startingOffset, ); } } diff --git a/zune_ui/lib/pages/music_page/playlist_list/playlist_list_tile.dart b/zune_ui/lib/pages/music_page/playlist_list/playlist_list_tile.dart index 95e4489..4b5c056 100644 --- a/zune_ui/lib/pages/music_page/playlist_list/playlist_list_tile.dart +++ b/zune_ui/lib/pages/music_page/playlist_list/playlist_list_tile.dart @@ -12,6 +12,7 @@ final Map parallaxConfig = { /// moves down more apparently. velocity: 1 * 8, signedDirection: 1, + constraints: null, ), /// Artist Title @@ -25,6 +26,7 @@ final Map parallaxConfig = { /// moves up making more space between title nad the album row. velocity: 2 * 2, signedDirection: -1, + constraints: null, ), /// Albums Row @@ -33,6 +35,7 @@ final Map parallaxConfig = { y: 32, velocity: 3 * 4, signedDirection: -1, + constraints: null, ), }; diff --git a/zune_ui/lib/pages/music_page/track_list/constants.dart b/zune_ui/lib/pages/music_page/track_list/constants.dart index 08f5d8a..fafc4e6 100644 --- a/zune_ui/lib/pages/music_page/track_list/constants.dart +++ b/zune_ui/lib/pages/music_page/track_list/constants.dart @@ -11,6 +11,7 @@ final Map TRACK_PARALLAX_CONFIG = { y: 0, velocity: 0, signedDirection: 0, + constraints: null, ), // Track Artist & Album Name 1: ( @@ -18,5 +19,6 @@ final Map TRACK_PARALLAX_CONFIG = { y: 20, velocity: 0, signedDirection: 0, + constraints: null, ), }; diff --git a/zune_ui/lib/pages/music_page/view_selector/index.dart b/zune_ui/lib/pages/music_page/view_selector/index.dart index 2a17f08..1b10445 100644 --- a/zune_ui/lib/pages/music_page/view_selector/index.dart +++ b/zune_ui/lib/pages/music_page/view_selector/index.dart @@ -4,11 +4,13 @@ import 'package:flutter/widgets.dart'; import 'package:zune_ui/enums/index.dart'; import 'package:provider/provider.dart'; import 'package:zune_ui/pages/music_page/album_grid/index.dart'; +import 'package:zune_ui/pages/music_page/album_list/index.dart'; import 'package:zune_ui/pages/music_page/artist_list/index.dart'; import 'package:zune_ui/pages/music_page/genre_list/index.dart'; import 'package:zune_ui/pages/music_page/playlist_list/index.dart'; import 'package:zune_ui/pages/music_page/track_list/index.dart'; import 'package:zune_ui/providers/global_state/index.dart'; +import 'package:zune_ui/providers/animation_provider/index.dart'; /// NOTE: Scoping imports behind parent, so that console log is exposed from Music Page import 'package:zune_ui/pages/music_page/index.dart' as parent; diff --git a/zune_ui/lib/pages/music_page/view_selector/view_mount_transition.dart b/zune_ui/lib/pages/music_page/view_selector/view_mount_transition.dart index ada3cd0..9675887 100644 --- a/zune_ui/lib/pages/music_page/view_selector/view_mount_transition.dart +++ b/zune_ui/lib/pages/music_page/view_selector/view_mount_transition.dart @@ -91,15 +91,15 @@ class _ViewMountTransitionState extends State WidgetsBinding.instance.addPostFrameCallback( (_) { final musicPlayerAnimationContext = - parent.MusicPlayerAnimationProvider.of(context); + MusicPlayerAnimationProvider.of(context); musicPlayerAnimationContext?.register( - parent.EventType.unmountEvent, + EventType.unmountEvent, () async { /// NOTE: This logic is responsible for performing unmounting animation. - /// First check if the widget is not yet disposed in order to reverse - /// _controller and update the animation type to be mount. - if (!_isDisposed) { + /// First check if the widget is not yet disposed and still mounted + /// in order to reverse _controller and update the animation type to be mount. + if (!_isDisposed && mounted) { setState(() { _forceUnmountAnimation = true; }); diff --git a/zune_ui/lib/pages/music_page/view_selector/view_selector.dart b/zune_ui/lib/pages/music_page/view_selector/view_selector.dart index b4828ef..fb2b55b 100644 --- a/zune_ui/lib/pages/music_page/view_selector/view_selector.dart +++ b/zune_ui/lib/pages/music_page/view_selector/view_selector.dart @@ -183,7 +183,8 @@ class _ViewSelectorState extends State Widget renderCategoryType(MusicCategoryType type) { switch (type) { case MusicCategoryType.albums: - return const AlbumsGrid(); + // return const AlbumsGrid(); + return const AlbumList(); case MusicCategoryType.genres: return const GenreList(); case MusicCategoryType.artists: diff --git a/zune_ui/lib/pages/player_page/page.dart b/zune_ui/lib/pages/player_page/page.dart index 071f8f0..c511ddf 100644 --- a/zune_ui/lib/pages/player_page/page.dart +++ b/zune_ui/lib/pages/player_page/page.dart @@ -43,7 +43,12 @@ class _PlayerPageState extends State with TickerProviderStateMixin { void onBackClick() { _controller.forward().then((_) { - context.go(ApplicationRoute.home.route); + if (context.canPop()) { + context.pop(); + } else { + // Fallback to home if there's no navigation history + context.go(ApplicationRoute.home.route); + } }); } diff --git a/zune_ui/lib/providers/animation_provider/animation_provider.dart b/zune_ui/lib/providers/animation_provider/animation_provider.dart new file mode 100644 index 0000000..0454e75 --- /dev/null +++ b/zune_ui/lib/providers/animation_provider/animation_provider.dart @@ -0,0 +1,78 @@ +part of animation_provider; + +enum EventType { + unmountEvent, + mountEvent, +} + +/// NOTE: This is provider responsible for managing animation +/// execution across multiple widgets globally. +/// +/// The key purpose is to schedule animation updates based +/// on the EventType and group Future-like calls together +/// to be executed when combined animation is required. +/// +/// Example: Unmounting event +/// Multiple widgets can register their unmount animations +/// with this provider. Each widget registers an unmount event +/// with a callback which will perform an animation change such +/// as forward/reverse. These events will be executed in order* +/// and followed by last call in executeWith callback. + +class MusicPlayerAnimationProvider extends InheritedWidget { + final Map Function()>> _map = {}; + + MusicPlayerAnimationProvider({ + super.key, + required super.child, + }); + + static MusicPlayerAnimationProvider? of(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType(); + } + + void register(EventType key, Future Function() cb) { + if (_map.containsKey(key)) { + _map[key]?.add(cb); + } else { + _map[key] = [cb]; + } + } + + void clear(EventType? key) { + if (key != null) { + _map.remove(key); + } else { + _map.clear(); + } + } + + void executeWith(Future Function() finalAction) async { + for (final actions in _map.values) { + await Future.wait( + actions.map((action) async { + try { + await action(); + } catch (e) { + console.error(e, customTags: ["animation_provider"]); + + /// NOTE: Ignore errors from disposed animation controllers + /// or other widget lifecycle issues. This can happen when + /// widgets are disposed before the animation sequence executes. + } + }), + ); + } + await finalAction(); + + /// NOTE: Clear registered callbacks after execution to prevent + /// accumulation of stale callbacks from disposed widgets. + _map.clear(); + } + + @override + bool updateShouldNotify(MusicPlayerAnimationProvider oldWidget) { + return true; + } +} diff --git a/zune_ui/lib/providers/animation_provider/index.dart b/zune_ui/lib/providers/animation_provider/index.dart new file mode 100644 index 0000000..0d9a394 --- /dev/null +++ b/zune_ui/lib/providers/animation_provider/index.dart @@ -0,0 +1,9 @@ +library animation_provider; + +import 'package:zune_ui/widgets/custom/debug_print.dart'; + +import 'package:flutter/widgets.dart'; + +part "animation_provider.dart"; + +final console = DebugPrint().register(DebugComponent.animation); diff --git a/zune_ui/lib/providers/global_state/global_state.dart b/zune_ui/lib/providers/global_state/global_state.dart index c195159..5a6bf7f 100644 --- a/zune_ui/lib/providers/global_state/global_state.dart +++ b/zune_ui/lib/providers/global_state/global_state.dart @@ -173,11 +173,18 @@ class GlobalModalState extends ChangeNotifier { ); } + /// NOTE: Used to get albums from ids for lazy loading. Future> getAlbumsFromIds( List album_ids) async { return _collector.getAlbumsFromIds(album_ids); } + /// NOTE: Used to get tracks from ids for lazy loading. + Future> getTracksFromIds( + List track_ids) async { + return _collector.getTracksFromIds(track_ids); + } + int _getNextPrevTrackIndex(int delta) { int nextPrevIndex = -1; if (_currentSongList.isNotEmpty && _currentlyPlaying != null) { diff --git a/zune_ui/lib/utilities/LRUCache.dart b/zune_ui/lib/utilities/LRUCache.dart new file mode 100644 index 0000000..fc84d35 --- /dev/null +++ b/zune_ui/lib/utilities/LRUCache.dart @@ -0,0 +1,64 @@ +part of utilities; + +/// A simple LRU (Least Recently Used) cache implementation. +/// +/// When the cache reaches [maxSize], the least recently used entry +/// is evicted when a new entry is added. +class LRUCache { + final int maxSize; + final LinkedHashMap _cache = LinkedHashMap(); + + LRUCache({required this.maxSize}) + : assert(maxSize > 0, 'maxSize must be greater than 0'); + + /// Returns the number of entries in the cache. + int get length => _cache.length; + + /// Returns true if the cache is empty. + bool get isEmpty => _cache.isEmpty; + + /// Returns true if the cache is not empty. + bool get isNotEmpty => _cache.isNotEmpty; + + /// Checks if the cache contains the given key. + bool containsKey(K key) { + if (_cache.containsKey(key)) { + // Move to end (most recently used) by removing and re-adding + final value = _cache.remove(key)!; + _cache[key] = value; + return true; + } + return false; + } + + /// Gets the value for the given key, or null if not found. + /// Accessing a key moves it to the most recently used position. + V? operator [](K key) { + if (_cache.containsKey(key)) { + // Move to end (most recently used) by removing and re-adding + final value = _cache.remove(key)!; + _cache[key] = value; + return value; + } + return null; + } + + /// Sets the value for the given key. + /// If the cache is at max size, the least recently used entry is evicted. + void operator []=(K key, V value) { + if (_cache.containsKey(key)) { + // Update existing: remove and re-add to mark as recently used + _cache.remove(key); + } else if (_cache.length >= maxSize) { + // Evict least recently used (first entry in LinkedHashMap) + _cache.remove(_cache.keys.first); + } + // Add to end (most recently used) + _cache[key] = value; + } + + /// Clears all entries from the cache. + void clear() { + _cache.clear(); + } +} diff --git a/zune_ui/lib/utilities/index.dart b/zune_ui/lib/utilities/index.dart new file mode 100644 index 0000000..3360ccd --- /dev/null +++ b/zune_ui/lib/utilities/index.dart @@ -0,0 +1,5 @@ +library utilities; + +import 'dart:collection'; + +part "LRUCache.dart"; diff --git a/zune_ui/lib/widgets/common/album_tile.dart b/zune_ui/lib/widgets/common/album_tile.dart index ae0c23d..bfda35f 100644 --- a/zune_ui/lib/widgets/common/album_tile.dart +++ b/zune_ui/lib/widgets/common/album_tile.dart @@ -123,6 +123,8 @@ class SearchIndexTile extends StatelessWidget { class SquareTile extends StatelessWidget { final String? text; + // NOTE: If child is provided, use it instead of text + final Widget? child; final Uint8List? background; final AlignmentGeometry? alignment; final TextStyle? textStyle; @@ -134,6 +136,7 @@ class SquareTile extends StatelessWidget { super.key, required this.size, this.text, + this.child, this.background, this.alignment = Alignment.bottomLeft, this.textStyle = TextStyles.albumTitle, @@ -179,16 +182,19 @@ class SquareTile extends StatelessWidget { cacheHeight: cacheSize, cacheWidth: cacheSize, ), - if (text != null) + if (text != null || child != null) Container( padding: const EdgeInsets.all(4.0), + // NOTE: If child is provided, use it instead of text + child: child ?? + Text( + text!, + style: textStyle, - /// NOTE: Zune only shows maximum of 3 lines from the title - child: Text( - text!, - style: textStyle, - maxLines: 3, - ), + /// NOTE: Zune only shows maximum of 3 lines from the title + + maxLines: 3, + ), ) ], ), diff --git a/zune_ui/lib/widgets/support_menu/menu.dart b/zune_ui/lib/widgets/support_menu/menu.dart index 460aa47..7f01a8e 100644 --- a/zune_ui/lib/widgets/support_menu/menu.dart +++ b/zune_ui/lib/widgets/support_menu/menu.dart @@ -12,7 +12,7 @@ class SupportMenu extends StatelessWidget { onItemClickHandler(GlobalModalState state) => (InteractiveItem item) { if (item is AlbumSummary) { state.updateCurrentlyPlaying(item); - context.go(ApplicationRoute.player.route); + context.push(ApplicationRoute.player.route); } }; @@ -39,7 +39,7 @@ class SupportMenu extends StatelessWidget { album: state.currentlyPlaying?.album, isPlaying: state.isPlaying, onClickHandler: (item) => - context.go(ApplicationRoute.player.route), + context.push(ApplicationRoute.player.route), ); }, ),