diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index e0b64950..5b9c4b02 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -7342,6 +7342,34 @@ } } }, + "homeTaglineListening": "What are we listening to?", + "@homeTaglineListening": { + "description": "Rotating Home prompt under the greeting" + }, + "homeTaglineLink": "Got a link? Drop it in the search", + "@homeTaglineLink": { + "description": "Rotating Home prompt explaining that links can be pasted into search" + }, + "homeTaglineDiscover": "Find your next favorite album", + "@homeTaglineDiscover": { + "description": "Rotating Home prompt under the greeting" + }, + "homeContinueListening": "Continue listening", + "@homeContinueListening": { + "description": "Home section header for the track currently loaded in the player" + }, + "homeRecentlyAdded": "Recently added", + "@homeRecentlyAdded": { + "description": "Home section header for the newest downloaded and scanned tracks" + }, + "homeOnRepeat": "On repeat", + "@homeOnRepeat": { + "description": "Home section header for the most played tracks of the last four weeks" + }, + "homeShortcuts": "Shortcuts", + "@homeShortcuts": { + "description": "Home section header for quick links to player tools" + }, "artistUpcomingConcerts": "Upcoming Concerts", "artistAllUpcomingConcerts": "All Upcoming Concerts", "artistNoUpcomingConcerts": "No upcoming concerts.", diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 9fbd67ff..dfc5ef4d 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -7314,6 +7314,13 @@ "homeGreetingNight2": "Teman begadang", "homeGreetingNight3": "Malam yang tenang", "homeGreetingNamed": "{greeting}, {name}", + "homeTaglineListening": "Mau dengar apa hari ini?", + "homeTaglineLink": "Punya link? Tempel saja di pencarian", + "homeTaglineDiscover": "Temukan album favoritmu berikutnya", + "homeContinueListening": "Lanjutkan mendengarkan", + "homeRecentlyAdded": "Baru ditambahkan", + "homeOnRepeat": "Sering diputar", + "homeShortcuts": "Pintasan", "artistUpcomingConcerts": "Konser Mendatang", "artistAllUpcomingConcerts": "Semua Konser Mendatang", "artistNoUpcomingConcerts": "Tiada konser mendatang.", diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 1d38cab6..ae6edf09 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -43,6 +43,16 @@ import 'package:spotiflac_android/providers/explore_provider.dart'; import 'package:spotiflac_android/providers/user_profile_provider.dart'; import 'package:spotiflac_android/widgets/explore_featured_section.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/models/unified_library_item.dart'; +import 'package:spotiflac_android/screens/equalizer_screen.dart'; +import 'package:spotiflac_android/screens/listening_statistics_screen.dart'; +import 'package:spotiflac_android/screens/network_storage_screen.dart'; +import 'package:spotiflac_android/screens/now_playing_screen.dart'; +import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/listening_statistics.dart'; +import 'package:spotiflac_android/services/music_player_service.dart' + show PlayableMedia; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart'; @@ -68,6 +78,7 @@ import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; +import 'package:spotiflac_android/widgets/player_artwork.dart'; import 'package:spotiflac_android/widgets/error_card.dart'; import 'package:spotiflac_android/widgets/in_library_badge.dart'; import 'package:spotiflac_android/widgets/preview_button.dart'; @@ -80,6 +91,7 @@ part 'home_tab_helpers.dart'; part 'home_tab_explore.dart'; part 'home_tab_recent.dart'; part 'home_tab_import.dart'; +part 'home_tab_intro.dart'; part 'home_tab_search_results.dart'; part 'home_tab_widgets.dart'; @@ -890,6 +902,23 @@ class _HomeTabState extends ConsumerState context, const AppMotionSpec(Duration(milliseconds: 250), Curves.easeOut), ); + // Without a feed, the Material Home greets in its header and gathers + // the player and shortcuts under the search bar. A feed brings its own + // greeting, and the Mornye style keeps its navigation title. + final greetsInHeader = + _showsHome && + widget.mode != HomeTabMode.search && + !context.isMornye && + !showExplore && + !showEmptyHomeState; + final greeting = greetsInHeader + ? homeGreeting( + context.l10n, + name: ref.watch( + userProfileProvider.select((p) => p.value?.name ?? ''), + ), + ) + : null; ref.listen(settingsProvider.select((s) => s.defaultSearchTab), ( previous, @@ -944,7 +973,7 @@ class _HomeTabState extends ConsumerState alignToContent: true, title: widget.mode == HomeTabMode.search ? context.l10n.mornyeSearch - : context.l10n.homeTitle, + : greeting ?? context.l10n.homeTitle, actions: context.isMornye && _showsHome ? const [HomeProfileButton()] : null, @@ -970,7 +999,11 @@ class _HomeTabState extends ConsumerState // Centered with the search results on tablets. padding: EdgeInsets.fromLTRB( 16 + wideListInset(context), - (hasResults || showExplore) ? 8 : 32, + (hasResults || showExplore) + ? 8 + : greetsInHeader + ? 20 + : 32, 16 + wideListInset(context), (hasResults || showExplore) ? 8 : 16, ), @@ -1038,7 +1071,11 @@ class _HomeTabState extends ConsumerState ? const SizedBox.shrink() : Column( children: [ - if (_showsSearch && !hasSearchedBefore) + if (greetsInHeader) + _HomeSections(inset: 16 + wideListInset(context)), + if (_showsSearch && + !hasSearchedBefore && + !greetsInHeader) Padding( padding: const EdgeInsets.only(top: 8), child: Text( @@ -1050,7 +1087,7 @@ class _HomeTabState extends ConsumerState ), ), ), - if (hasHistoryItems) + if (hasHistoryItems && !greetsInHeader) Consumer( builder: (context, ref, _) { final historyItems = ref.watch( @@ -1229,47 +1266,12 @@ class _HomeTabState extends ConsumerState // in the content area. if (context.isMornye) return const SizedBox.shrink(); + // The header carries the greeting; prompts take turns beneath it. return Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - children: [ - SizedBox(height: screenHeight * 0.06), - AppShapeBadge( - kind: AppShapes.brand, - size: 96, - color: colorScheme.primary, - child: Image.asset( - 'assets/images/logo-transparent.png', - color: colorScheme.onPrimary, - fit: BoxFit.contain, - errorBuilder: (_, _, _) => ClipRRect( - borderRadius: BorderRadius.circular(24), - child: Image.asset( - 'assets/images/logo.png', - width: 96, - height: 96, - fit: BoxFit.cover, - ), - ), - ), - ), - const SizedBox(height: 16), - Text( - 'SpotiFLAC Mobile', - textAlign: TextAlign.center, - style: Theme.of( - context, - ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Text( - context.l10n.homeSubtitle, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], + padding: EdgeInsets.symmetric(horizontal: 24 + wideListInset(context)), + child: const Align( + alignment: AlignmentDirectional.centerStart, + child: _HomeTagline(), ), ); } diff --git a/lib/screens/home_tab_intro.dart b/lib/screens/home_tab_intro.dart new file mode 100644 index 00000000..b500bef9 --- /dev/null +++ b/lib/screens/home_tab_intro.dart @@ -0,0 +1,762 @@ +part of 'home_tab.dart'; + +const _homeShelfLimit = 10; +const _homeShelfCoverSize = 120.0; + +/// The newest downloaded and scanned tracks, with the Library's track count +/// for the shortcut at the end of the shelf. +final _homeRecentlyAddedProvider = + FutureProvider.autoDispose< + ({List items, int trackCount}) + >((ref) async { + final includeLocal = ref.watch( + settingsProvider.select((s) => s.localLibraryEnabled), + ); + // Reload when a download lands or a scan rebuilds the index. + ref.watch(downloadHistoryProvider.select((s) => s.items.length)); + ref.watch(localLibraryProvider.select((s) => s.loadedIndexVersion)); + final database = LibraryDatabase.instance; + final rows = await database.getQueueTrackPage( + QueueLibraryDbQuery(includeLocal: includeLocal, limit: _homeShelfLimit), + ); + final counts = await database.getQueueCounts( + QueueLibraryDbQuery(includeLocal: includeLocal), + ); + final items = []; + for (final row in rows) { + final json = Map.from(row['item'] as Map); + switch (row['source']) { + case 'local': + items.add( + UnifiedLibraryItem.fromLocalLibrary( + LocalLibraryItem.fromJson(json), + ), + ); + case 'downloaded': + items.add( + UnifiedLibraryItem.fromDownloadHistory( + DownloadHistoryItem.fromJson(json), + ), + ); + } + } + return (items: items, trackCount: counts.allTrackCount); + }); + +/// The most played files of the last four weeks. +final _homeOnRepeatProvider = FutureProvider.autoDispose>(( + ref, +) async { + // A new track means the previous one may have just earned a play. + ref.watch(currentMediaItemProvider.select((s) => s.value?.id)); + await listeningRecorder.flush(); + final now = DateTime.now(); + final summary = await ListeningStatisticsStore.instance.load( + DateTime(now.year, now.month, now.day - 27), + DateTime(now.year, now.month, now.day + 1), + ); + return [ + for (final total in summary.tracks) + // Remote sources cannot be replayed from their recorded key. + if (total.plays > 0 && + !total.track.key.startsWith('http://') && + !total.track.key.startsWith('https://')) + total.track, + ].take(_homeShelfLimit).toList(growable: false); +}); + +/// The prompt under the Home greeting, picked again on each launch. +class _HomeTagline extends StatelessWidget { + const _HomeTagline(); + + @override + Widget build(BuildContext context) => Text( + homeTagline(context.l10n), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); +} + +/// Everything under the search bar on the Material Home. Sections without +/// content leave no gap behind. +class _HomeSections extends ConsumerWidget { + const _HomeSections({required this.inset}); + + /// Horizontal page margin, wider on tablets. + final double inset; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final hasMediaItem = ref.watch( + currentMediaItemProvider.select((s) => s.value != null), + ); + final recentlyAdded = ref.watch(_homeRecentlyAddedProvider).value; + final onRepeat = ref.watch(_homeOnRepeatProvider).value ?? const []; + + final sections = [ + if (hasMediaItem) + _HomeSection( + title: l10n.homeContinueListening, + inset: inset, + child: const _HomeContinueListeningCard(), + ), + _HomeSection( + title: l10n.homeShortcuts, + inset: inset, + child: const _HomeShortcuts(), + ), + if (recentlyAdded != null && recentlyAdded.items.isNotEmpty) + _HomeSection( + title: l10n.homeRecentlyAdded, + inset: inset, + shelf: true, + child: _HomeRecentlyAddedShelf( + items: recentlyAdded.items, + trackCount: recentlyAdded.trackCount, + inset: inset, + ), + ), + if (onRepeat.isNotEmpty) + _HomeSection( + title: l10n.homeOnRepeat, + inset: inset, + shelf: true, + child: _HomeOnRepeatShelf(tracks: onRepeat, inset: inset), + ), + ]; + + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final (index, section) in sections.indexed) ...[ + if (index > 0) const SizedBox(height: 28), + section, + ], + ], + ), + ); + } +} + +class _HomeSection extends StatelessWidget { + const _HomeSection({ + required this.title, + required this.inset, + required this.child, + this.shelf = false, + }); + + final String title; + final double inset; + final Widget child; + + /// Shelves scroll edge to edge and pad their own items. + final bool shelf; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(inset + 8, 0, inset + 8, 12), + child: Semantics( + header: true, + child: Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + if (shelf) + child + else + Padding( + padding: EdgeInsets.symmetric(horizontal: inset), + child: child, + ), + ], + ); + } +} + +/// The track loaded in the player, with its progress and a play-pause +/// button. +class _HomeContinueListeningCard extends ConsumerWidget { + const _HomeContinueListeningCard(); + + static const _coverSize = 96.0; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mediaItem = ref.watch(currentMediaItemProvider).value; + if (mediaItem == null) return const SizedBox.shrink(); + + final isPlaying = ref.watch(playbackPlayingProvider); + final isLoading = ref.watch(playbackLoadingProvider); + final controller = ref.read(musicPlayerControllerProvider); + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final artist = mediaItem.artist ?? ''; + + return Material( + color: colorScheme.primaryContainer, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => Navigator.of( + context, + rootNavigator: true, + ).push(NowPlayingRoute(child: const NowPlayingScreen())), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 16, 12), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(20), + child: SizedBox.square( + dimension: _coverSize, + child: PlayerArtwork( + artUri: mediaItem.artUri?.toString(), + colorScheme: colorScheme, + cacheWidth: + (_coverSize * MediaQuery.devicePixelRatioOf(context)) + .round(), + iconSize: 32, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + mediaItem.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.titleLarge?.copyWith( + color: colorScheme.onPrimaryContainer, + ), + ), + if (artist.isNotEmpty) + Text( + artist, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onPrimaryContainer.withValues( + alpha: 0.8, + ), + ), + ), + const SizedBox(height: 12), + _HomeContinueListeningProgress( + duration: mediaItem.duration ?? Duration.zero, + ), + ], + ), + ), + const SizedBox(width: 12), + Tooltip( + message: isPlaying + ? context.l10n.actionPause + : context.l10n.tooltipPlay, + child: Semantics( + button: true, + child: Material( + type: MaterialType.transparency, + child: InkWell( + customBorder: const CircleBorder(), + onTap: isLoading + ? null + : () => controller.togglePlayPause(isPlaying), + child: AppShapeBadge( + kind: M3EShapeKind.sunny, + size: 56, + color: colorScheme.primary, + child: isLoading + ? AppLoadingIndicator( + size: 24, + color: colorScheme.onPrimary, + ) + : Icon( + isPlaying ? Icons.pause : Icons.play_arrow, + size: 28, + color: colorScheme.onPrimary, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Follows position ticks on its own so the card above does not rebuild on +/// every one. +class _HomeContinueListeningProgress extends ConsumerWidget { + const _HomeContinueListeningProgress({required this.duration}); + + final Duration duration; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + final durationMs = duration.inMilliseconds; + final positionMs = ref.watch(playbackPositionProvider).inMilliseconds; + final progress = durationMs > 0 + ? (positionMs / durationMs).clamp(0.0, 1.0) + : 0.0; + return ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: progress, + minHeight: 4, + color: colorScheme.primary, + backgroundColor: colorScheme.onPrimaryContainer.withValues(alpha: 0.16), + ), + ); + } +} + +/// A horizontal row of cover tiles that scrolls past the page margin. +class _HomeShelf extends StatelessWidget { + const _HomeShelf({ + required this.inset, + required this.itemCount, + required this.itemBuilder, + }); + + final double inset; + final int itemCount; + final IndexedWidgetBuilder itemBuilder; + + @override + Widget build(BuildContext context) { + final textScale = MediaQuery.textScalerOf(context).scale(1); + return SizedBox( + height: _homeShelfCoverSize + 52 * textScale, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: EdgeInsets.symmetric(horizontal: inset), + itemCount: itemCount, + separatorBuilder: (_, _) => const SizedBox(width: 12), + itemBuilder: itemBuilder, + ), + ); + } +} + +class _HomeShelfTile extends StatelessWidget { + const _HomeShelfTile({ + required this.cover, + required this.title, + required this.subtitle, + required this.semanticLabel, + required this.onTap, + }); + + final Widget cover; + final String title; + final String subtitle; + final String semanticLabel; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + return Semantics( + button: true, + label: semanticLabel, + excludeSemantics: true, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(20), + child: SizedBox( + width: _homeShelfCoverSize, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(20), + child: SizedBox.square( + dimension: _homeShelfCoverSize, + child: cover, + ), + ), + const SizedBox(height: 8), + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.labelLarge?.copyWith( + color: colorScheme.onSurface, + ), + ), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _HomeRecentlyAddedShelf extends ConsumerWidget { + const _HomeRecentlyAddedShelf({ + required this.items, + required this.trackCount, + required this.inset, + }); + + final List items; + final int trackCount; + final double inset; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + final cacheWidth = + (_homeShelfCoverSize * MediaQuery.devicePixelRatioOf(context)).round(); + return _HomeShelf( + inset: inset, + itemCount: items.length + 1, + itemBuilder: (context, index) { + if (index == items.length) { + return _HomeLibraryTile(trackCount: trackCount); + } + final item = items[index]; + final history = item.historyItem; + return _HomeShelfTile( + title: item.trackName, + subtitle: item.artistName, + semanticLabel: context.l10n.a11yOpenTrackByArtist( + item.trackName, + item.artistName, + ), + cover: history != null + ? _DownloadedOrRemoteCover( + downloadedFilePath: history.filePath, + imageUrl: history.coverUrl, + width: _homeShelfCoverSize, + height: _homeShelfCoverSize, + borderRadius: BorderRadius.zero, + fallbackIcon: Icons.music_note, + fallbackIconSize: 32, + colorScheme: colorScheme, + ) + : PlayerArtwork( + artUri: item.localCoverPath, + colorScheme: colorScheme, + cacheWidth: cacheWidth, + iconSize: 32, + ), + onTap: () => _play(ref, index), + ); + }, + ); + } + + void _play(WidgetRef ref, int index) { + final media = []; + var initialIndex = 0; + for (final (i, item) in items.indexed) { + final playable = switch (item) { + UnifiedLibraryItem(:final historyItem?) => playableFromHistory( + historyItem, + ), + UnifiedLibraryItem(:final localItem?) => playableFromLocal(localItem), + _ => null, + }; + if (playable == null || playable.source.trim().isEmpty) continue; + if (i == index) initialIndex = media.length; + media.add(playable); + } + if (media.isEmpty) return; + ref + .read(musicPlayerControllerProvider) + .playAll(media, initialIndex: initialIndex); + } +} + +/// Ends the Recently added shelf with a way into the whole Library. +class _HomeLibraryTile extends StatelessWidget { + const _HomeLibraryTile({required this.trackCount}); + + final int trackCount; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + return Align( + alignment: Alignment.topCenter, + child: SizedBox.square( + dimension: _homeShelfCoverSize, + child: Material( + color: colorScheme.tertiaryContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => ShellNavigationService.requestTab(ShellTab.library), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + AppShapeBadge( + kind: M3EShapeKind.clover4Leaf, + size: 40, + color: colorScheme.tertiary, + child: Icon( + Icons.library_music, + size: 20, + color: colorScheme.onTertiary, + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.navLibrary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.labelLarge?.copyWith( + color: colorScheme.onTertiaryContainer, + ), + ), + Text( + context.l10n.tracksCount(trackCount), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onTertiaryContainer, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _HomeOnRepeatShelf extends ConsumerWidget { + const _HomeOnRepeatShelf({required this.tracks, required this.inset}); + + final List tracks; + final double inset; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + final cacheWidth = + (_homeShelfCoverSize * MediaQuery.devicePixelRatioOf(context)).round(); + return _HomeShelf( + inset: inset, + itemCount: tracks.length, + itemBuilder: (context, index) { + final track = tracks[index]; + return _HomeShelfTile( + title: track.title, + subtitle: track.artist, + semanticLabel: context.l10n.a11yOpenTrackByArtist( + track.title, + track.artist, + ), + cover: PlayerArtwork( + artUri: track.artwork, + colorScheme: colorScheme, + cacheWidth: cacheWidth, + iconSize: 32, + ), + onTap: () => ref.read(musicPlayerControllerProvider).playAll([ + for (final track in tracks) + PlayableMedia( + id: track.key, + source: track.key, + title: track.title, + artist: track.artist, + album: track.album, + artUri: track.artwork, + ), + ], initialIndex: index), + ); + }, + ); + } +} + +/// Quick ways into player tools that otherwise sit a few screens deep. +class _HomeShortcuts extends ConsumerWidget { + const _HomeShortcuts(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final colorScheme = Theme.of(context).colorScheme; + void open(Widget screen) => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => screen)); + + final tiles = [ + _HomeShortcutTile( + label: l10n.nowPlayingShuffleLibrary, + icon: Icons.shuffle, + shape: M3EShapeKind.cookie9Sided, + container: colorScheme.primary, + onContainer: colorScheme.onPrimary, + badge: colorScheme.onPrimary, + onBadge: colorScheme.primary, + onTap: () => _shuffle(context, ref), + ), + _HomeShortcutTile( + label: l10n.equalizerTitle, + icon: Icons.graphic_eq, + shape: M3EShapeKind.cookie4Sided, + container: colorScheme.tertiaryContainer, + onContainer: colorScheme.onTertiaryContainer, + badge: colorScheme.tertiary, + onBadge: colorScheme.onTertiary, + onTap: () => open(const EqualizerScreen()), + ), + _HomeShortcutTile( + label: l10n.listeningStats, + icon: Icons.insights, + shape: M3EShapeKind.sunny, + container: colorScheme.secondaryContainer, + onContainer: colorScheme.onSecondaryContainer, + badge: colorScheme.secondary, + onBadge: colorScheme.onSecondary, + onTap: () => open(const ListeningStatisticsScreen()), + ), + _HomeShortcutTile( + label: l10n.networkStorage, + icon: Icons.lan_outlined, + shape: M3EShapeKind.pentagon, + container: colorScheme.surfaceContainerHigh, + onContainer: colorScheme.onSurface, + badge: colorScheme.primaryContainer, + onBadge: colorScheme.onPrimaryContainer, + onTap: () => open(const NetworkStorageScreen()), + ), + ]; + + return Column( + children: [ + for (var row = 0; row < tiles.length; row += 2) ...[ + if (row > 0) const SizedBox(height: 8), + // A label that wraps keeps its neighbor the same height. + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: tiles[row]), + const SizedBox(width: 8), + Expanded(child: tiles[row + 1]), + ], + ), + ), + ], + ], + ); + } + + Future _shuffle(BuildContext context, WidgetRef ref) async { + final shuffled = await ref + .read(musicPlayerControllerProvider) + .shuffleLibrary(); + if (shuffled || !context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.nowPlayingLibraryEmpty)), + ); + } +} + +class _HomeShortcutTile extends StatelessWidget { + const _HomeShortcutTile({ + required this.label, + required this.icon, + required this.shape, + required this.container, + required this.onContainer, + required this.badge, + required this.onBadge, + required this.onTap, + }); + + final String label; + final IconData icon; + final M3EShapeKind shape; + final Color container; + final Color onContainer; + final Color badge; + final Color onBadge; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: container, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppShapeBadge( + kind: shape, + size: 40, + color: badge, + child: Icon(icon, size: 20, color: onBadge), + ), + const SizedBox(height: 16), + Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(color: onContainer), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/home_tab_search_results.dart b/lib/screens/home_tab_search_results.dart index 678865b2..80299637 100644 --- a/lib/screens/home_tab_search_results.dart +++ b/lib/screens/home_tab_search_results.dart @@ -801,6 +801,12 @@ extension _HomeTabSearchResultsUI on _HomeTabState { Widget _buildSearchBar() { final hasText = _urlController.text.isNotEmpty; + // The Material Home keeps the resting field clean; import and paste + // appear once it is focused. + final showInputActions = + context.isMornye || + widget.mode == HomeTabMode.search || + _searchFocusNode.hasFocus; return AppSearchField( controller: _urlController, @@ -830,7 +836,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState { onPressed: _clearAndRefresh, tooltip: context.l10n.dialogClear, ) - else ...[ + else if (showInputActions) ...[ AppIconButton( icon: const Icon(Icons.file_upload_outlined), onPressed: _isCsvImporting diff --git a/lib/utils/home_greeting.dart b/lib/utils/home_greeting.dart index d72351fd..971c5f47 100644 --- a/lib/utils/home_greeting.dart +++ b/lib/utils/home_greeting.dart @@ -43,3 +43,15 @@ String homeGreeting( // A full name reads stiff in a headline. return l10n.homeGreetingNamed(phrase, trimmed.split(RegExp(r'\s+')).first); } + +/// The prompt under the Home greeting. Like the greeting, it holds for the +/// whole launch. +String homeTagline(AppLocalizations l10n, {int? variant}) { + final options = [ + l10n.homeTaglineListening, + l10n.homeTaglineLink, + l10n.homeTaglineDiscover, + ]; + // Offset from the greeting's pick so the two do not always pair up. + return options[((variant ?? _sessionVariant) ~/ 3) % options.length]; +}