diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 8830c6f5..0e9a1b8b 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -1,9 +1,9 @@ import 'dart:io'; -import 'dart:ui' show ImageFilter; import 'package:cached_network_image/cached_network_image.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; @@ -76,13 +76,6 @@ class _LibraryTracksFolderScreenState return (mediaSize.height * 0.6).clamp(400.0, 580.0); } - double _folderTitleFontSize(String title) { - final length = title.trim().length; - if (length > 45) return 18; - if (length > 30) return 21; - return 24; - } - IconData _modeIcon() { return switch (widget.mode) { LibraryTracksFolderMode.wishlist => Icons.bookmark, @@ -532,29 +525,146 @@ class _LibraryTracksFolderScreenState customCoverPath != null && customCoverPath.isNotEmpty; final hasCoverUrl = coverUrl != null; - return SliverAppBar( + final dpr = MediaQuery.devicePixelRatioOf(context); + final cacheWidth = (MediaQuery.sizeOf(context).width * dpr).round().clamp( + 320, + 2048, + ); + final coverFallback = Container( + color: colorScheme.surfaceContainerHighest, + child: Icon(_modeIcon(), size: 80, color: colorScheme.onSurfaceVariant), + ); + Widget squarePlaceholder() => Container( + color: colorScheme.surfaceContainerHighest, + child: Icon(_modeIcon(), size: 48, color: colorScheme.onSurfaceVariant), + ); + + final Widget background = hasCustomCover + ? Image.file( + File(customCoverPath), + fit: BoxFit.cover, + cacheWidth: cacheWidth, + filterQuality: FilterQuality.low, + gaplessPlayback: true, + frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + return coverFallback; + }, + errorBuilder: (_, _, _) => coverFallback, + ) + : hasCoverUrl + ? _isCoverLocalPath(coverUrl) + ? Image.file( + File(coverUrl), + fit: BoxFit.cover, + cacheWidth: cacheWidth, + filterQuality: FilterQuality.low, + gaplessPlayback: true, + frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + return Container(color: colorScheme.surface); + }, + errorBuilder: (_, _, _) => + Container(color: colorScheme.surface), + ) + : CachedNetworkImage( + imageUrl: highResCoverUrl(coverUrl) ?? coverUrl, + fit: BoxFit.cover, + memCacheWidth: cacheWidth, + cacheManager: CoverCacheManager.instance, + placeholder: (_, _) => Container(color: colorScheme.surface), + errorWidget: (_, _, _) => + Container(color: colorScheme.surface), + ) + : coverFallback; + + return AlbumDetailHeader( + title: title, + appBarTitle: _isSelectionMode + ? context.l10n.selectionSelected(_selectedKeys.length) + : title, expandedHeight: expandedHeight, - pinned: true, - stretch: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - title: AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: _showTitleInAppBar ? 1.0 : 0.0, - child: Text( - _isSelectionMode - ? context.l10n.selectionSelected(_selectedKeys.length) - : title, - style: TextStyle( - color: colorScheme.onSurface, - fontWeight: FontWeight.w600, - fontSize: 16, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - actions: [ + showTitleInAppBar: _showTitleInAppBar, + background: background, + blurAndScrimBackground: hasCustomCover || hasCoverUrl, + coverBuilder: (context, coverSize) { + if (hasCustomCover) { + return Image.file( + File(customCoverPath), + fit: BoxFit.cover, + width: coverSize, + height: coverSize, + cacheWidth: cacheWidth, + gaplessPlayback: true, + errorBuilder: (_, _, _) => squarePlaceholder(), + ); + } + if (hasCoverUrl && _isCoverLocalPath(coverUrl)) { + return Image.file( + File(coverUrl), + fit: BoxFit.cover, + width: coverSize, + height: coverSize, + cacheWidth: cacheWidth, + gaplessPlayback: true, + errorBuilder: (_, _, _) => squarePlaceholder(), + ); + } + if (hasCoverUrl) { + return CachedNetworkImage( + imageUrl: highResCoverUrl(coverUrl) ?? coverUrl, + fit: BoxFit.cover, + width: coverSize, + height: coverSize, + memCacheWidth: cacheWidth, + cacheManager: CoverCacheManager.instance, + placeholder: (_, _) => squarePlaceholder(), + errorWidget: (_, _, _) => squarePlaceholder(), + ); + } + return squarePlaceholder(); + }, + meta: entries.isNotEmpty + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(_modeIcon(), size: 14, color: Colors.white), + const SizedBox(width: 4), + Text( + context.l10n.tracksCount(entries.length), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ], + ), + ) + : null, + actions: entries.isNotEmpty + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildHeaderActionPlaceholder(), + const SizedBox(width: 12), + _buildDownloadAllCenterButton(entries), + const SizedBox(width: 12), + _buildHeaderActionPlaceholder(), + ], + ) + : null, + appBarActions: [ if (isPlaylistMode && !_isSelectionMode) ...[ IconButton( tooltip: context.l10n.collectionRenamePlaylist, @@ -593,250 +703,6 @@ class _LibraryTracksFolderScreenState ), ], ], - flexibleSpace: LayoutBuilder( - builder: (context, constraints) { - final collapseRatio = - (constraints.maxHeight - kToolbarHeight) / - (expandedHeight - kToolbarHeight); - final showContent = collapseRatio > 0.3; - final dpr = MediaQuery.devicePixelRatioOf(context); - final cacheWidth = (MediaQuery.sizeOf(context).width * dpr) - .round() - .clamp(320, 2048); - final coverFallback = Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - _modeIcon(), - size: 80, - color: colorScheme.onSurfaceVariant, - ), - ); - - return FlexibleSpaceBar( - collapseMode: CollapseMode.pin, - background: Stack( - fit: StackFit.expand, - children: [ - if (hasCustomCover) - ImageFiltered( - imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32), - child: Image.file( - File(customCoverPath), - fit: BoxFit.cover, - cacheWidth: cacheWidth, - filterQuality: FilterQuality.low, - gaplessPlayback: true, - frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) { - return child; - } - return coverFallback; - }, - errorBuilder: (_, _, _) => coverFallback, - ), - ) - else if (hasCoverUrl) - _isCoverLocalPath(coverUrl) - ? ImageFiltered( - imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32), - child: Image.file( - File(coverUrl), - fit: BoxFit.cover, - cacheWidth: cacheWidth, - filterQuality: FilterQuality.low, - gaplessPlayback: true, - frameBuilder: - (_, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) { - return child; - } - return Container(color: colorScheme.surface); - }, - errorBuilder: (_, _, _) => - Container(color: colorScheme.surface), - ), - ) - : ImageFiltered( - imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32), - child: CachedNetworkImage( - imageUrl: highResCoverUrl(coverUrl) ?? coverUrl, - fit: BoxFit.cover, - memCacheWidth: cacheWidth, - cacheManager: CoverCacheManager.instance, - placeholder: (_, _) => - Container(color: colorScheme.surface), - errorWidget: (_, _, _) => - Container(color: colorScheme.surface), - ), - ) - else - coverFallback, - if (hasCustomCover || hasCoverUrl) - Container(color: Colors.black.withValues(alpha: 0.35)), - Positioned( - left: 0, - right: 0, - bottom: 0, - height: expandedHeight * 0.65, - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.85), - ], - ), - ), - ), - ), - Positioned( - left: 20, - right: 20, - bottom: 40, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 150), - opacity: showContent ? 1.0 : 0.0, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Builder( - builder: (context) { - final coverSize = (constraints.maxWidth * 0.5) - .clamp(150.0, 210.0) - .toDouble(); - Widget squarePlaceholder() => Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - _modeIcon(), - size: 48, - color: colorScheme.onSurfaceVariant, - ), - ); - Widget coverChild; - if (hasCustomCover) { - coverChild = Image.file( - File(customCoverPath), - fit: BoxFit.cover, - width: coverSize, - height: coverSize, - cacheWidth: cacheWidth, - gaplessPlayback: true, - errorBuilder: (_, _, _) => squarePlaceholder(), - ); - } else if (hasCoverUrl && - _isCoverLocalPath(coverUrl)) { - coverChild = Image.file( - File(coverUrl), - fit: BoxFit.cover, - width: coverSize, - height: coverSize, - cacheWidth: cacheWidth, - gaplessPlayback: true, - errorBuilder: (_, _, _) => squarePlaceholder(), - ); - } else if (hasCoverUrl) { - coverChild = CachedNetworkImage( - imageUrl: highResCoverUrl(coverUrl) ?? coverUrl, - fit: BoxFit.cover, - width: coverSize, - height: coverSize, - memCacheWidth: cacheWidth, - cacheManager: CoverCacheManager.instance, - placeholder: (_, _) => squarePlaceholder(), - errorWidget: (_, _, _) => squarePlaceholder(), - ); - } else { - coverChild = squarePlaceholder(); - } - return Container( - width: coverSize, - height: coverSize, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.45), - blurRadius: 24, - offset: const Offset(0, 8), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: coverChild, - ), - ); - }, - ), - const SizedBox(height: 20), - Text( - title, - style: TextStyle( - color: Colors.white, - fontSize: _folderTitleFontSize(title), - fontWeight: FontWeight.bold, - height: 1.2, - ), - textAlign: TextAlign.center, - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - if (entries.isNotEmpty) ...[ - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - _modeIcon(), - size: 14, - color: Colors.white, - ), - const SizedBox(width: 4), - Text( - context.l10n.tracksCount(entries.length), - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ], - ), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildHeaderActionPlaceholder(), - const SizedBox(width: 12), - _buildDownloadAllCenterButton(entries), - const SizedBox(width: 12), - _buildHeaderActionPlaceholder(), - ], - ), - ], - ], - ), - ), - ), - ], - ), - stretchModes: const [StretchMode.zoomBackground], - ); - }, - ), leading: IconButton( tooltip: _isSelectionMode ? MaterialLocalizations.of(context).closeButtonTooltip diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 7447e48e..438446af 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'dart:ui' show ImageFilter; +import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; @@ -251,242 +251,83 @@ class _PlaylistScreenState extends ConsumerState { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { final expandedHeight = _calculateExpandedHeight(context); + final cacheWidth = coverCacheWidthForViewport(context); + final motionUrl = _headerVideoUrl; + final hasMotion = + motionUrl != null && + motionUrl.trim().isNotEmpty && + Uri.tryParse(motionUrl)?.hasAuthority == true; + Widget playlistPlaceholder({double? size}) { + return Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.playlist_play, + size: size ?? 80, + color: colorScheme.onSurfaceVariant, + ), + ); + } - return SliverAppBar( + final Widget coverImage = _coverUrl != null + ? CachedCoverImage( + imageUrl: highResCoverUrl(_coverUrl) ?? _coverUrl!, + fit: BoxFit.cover, + memCacheWidth: cacheWidth, + placeholder: (_, _) => Container(color: colorScheme.surface), + errorWidget: (_, _, _) => Container(color: colorScheme.surface), + ) + : playlistPlaceholder(); + + return AlbumDetailHeader( + title: _playlistName, expandedHeight: expandedHeight, - pinned: true, - stretch: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - title: AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: _showTitleInAppBar ? 1.0 : 0.0, - child: Text( - _playlistName, - style: TextStyle( - color: colorScheme.onSurface, - fontWeight: FontWeight.w600, - fontSize: 16, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + showTitleInAppBar: _showTitleInAppBar, + background: hasMotion + ? MotionHeaderBanner(videoUrl: motionUrl, fallback: coverImage) + : coverImage, + blurAndScrimBackground: !hasMotion && _coverUrl != null, + coverBuilder: hasMotion + ? null + : (context, coverSize) => _coverUrl != null + ? CachedCoverImage( + imageUrl: highResCoverUrl(_coverUrl) ?? _coverUrl!, + fit: BoxFit.cover, + memCacheWidth: cacheWidth, + placeholder: (_, _) => playlistPlaceholder(), + errorWidget: (_, _, _) => playlistPlaceholder(size: 48), + ) + : playlistPlaceholder(size: 48), + meta: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), ), - ), - flexibleSpace: LayoutBuilder( - builder: (context, constraints) { - final collapseRatio = - (constraints.maxHeight - kToolbarHeight) / - (expandedHeight - kToolbarHeight); - final showContent = collapseRatio > 0.3; - final cacheWidth = coverCacheWidthForViewport(context); - final motionUrl = _headerVideoUrl; - final hasMotion = - motionUrl != null && - motionUrl.trim().isNotEmpty && - Uri.tryParse(motionUrl)?.hasAuthority == true; - Widget playlistPlaceholder({double? size}) { - return Container( - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(16), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.playlist_play, size: 14, color: Colors.white), + const SizedBox(width: 4), + Text( + context.l10n.tracksCount(_tracks.length), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, ), - child: Icon( - Icons.playlist_play, - size: size ?? 80, - color: colorScheme.onSurfaceVariant, - ), - ); - } - - return FlexibleSpaceBar( - collapseMode: CollapseMode.pin, - background: Stack( - fit: StackFit.expand, - children: [ - if (hasMotion) - MotionHeaderBanner( - videoUrl: motionUrl, - fallback: _coverUrl != null - ? CachedCoverImage( - imageUrl: highResCoverUrl(_coverUrl) ?? _coverUrl!, - fit: BoxFit.cover, - memCacheWidth: cacheWidth, - placeholder: (_, _) => - Container(color: colorScheme.surface), - errorWidget: (_, _, _) => - Container(color: colorScheme.surface), - ) - : playlistPlaceholder(), - ) - else if (_coverUrl != null) - ImageFiltered( - imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32), - child: CachedCoverImage( - imageUrl: highResCoverUrl(_coverUrl) ?? _coverUrl!, - fit: BoxFit.cover, - memCacheWidth: cacheWidth, - placeholder: (_, _) => - Container(color: colorScheme.surface), - errorWidget: (_, _, _) => - Container(color: colorScheme.surface), - ), - ) - else - playlistPlaceholder(), - Container(color: Colors.black.withValues(alpha: 0.35)), - Positioned( - left: 0, - right: 0, - bottom: 0, - height: expandedHeight * 0.65, - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.6), - ], - ), - ), - ), - ), - Positioned.fill( - child: AnimatedOpacity( - duration: const Duration(milliseconds: 150), - opacity: showContent ? 1.0 : 0.0, - child: Padding( - padding: const EdgeInsets.fromLTRB( - 20, - kToolbarHeight + 8, - 20, - 28, - ), - child: Column( - mainAxisAlignment: hasMotion - ? MainAxisAlignment.end - : MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (!hasMotion) ...[ - Builder( - builder: (context) { - final coverSize = (constraints.maxWidth * 0.5) - .clamp(140.0, 220.0); - return Container( - width: coverSize, - height: coverSize, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues( - alpha: 0.45, - ), - blurRadius: 24, - offset: const Offset(0, 8), - ), - ], - ), - child: _coverUrl != null - ? CachedCoverImage( - imageUrl: - highResCoverUrl(_coverUrl) ?? - _coverUrl!, - fit: BoxFit.cover, - memCacheWidth: cacheWidth, - borderRadius: BorderRadius.circular( - 16, - ), - placeholder: (_, _) => - playlistPlaceholder(), - errorWidget: (_, _, _) => - playlistPlaceholder(size: 48), - ) - : playlistPlaceholder(size: 48), - ); - }, - ), - const SizedBox(height: 20), - ], - Text( - _playlistName, - style: const TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - height: 1.2, - ), - textAlign: TextAlign.center, - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 34), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.playlist_play, - size: 14, - color: Colors.white, - ), - const SizedBox(width: 4), - Text( - context.l10n.tracksCount(_tracks.length), - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - ], - ), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildLoveAllButton(), - const SizedBox(width: 12), - Flexible( - child: _buildDownloadAllCenterButton(context), - ), - const SizedBox(width: 12), - _buildAddToPlaylistButton(context), - ], - ), - ], - ), - ), - ), - ), - ], ), - stretchModes: const [StretchMode.zoomBackground], - ); - }, - ), - leading: IconButton( - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.4), - shape: BoxShape.circle, - ), - child: const Icon(Icons.arrow_back, color: Colors.white), + ], ), - onPressed: () => Navigator.pop(context), + ), + actions: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildLoveAllButton(), + const SizedBox(width: 12), + Flexible(child: _buildDownloadAllCenterButton(context)), + const SizedBox(width: 12), + _buildAddToPlaylistButton(context), + ], ), ); } diff --git a/lib/widgets/album_detail_header.dart b/lib/widgets/album_detail_header.dart index acebe832..cc0646c2 100644 --- a/lib/widgets/album_detail_header.dart +++ b/lib/widgets/album_detail_header.dart @@ -20,6 +20,8 @@ class AlbumDetailHeader extends StatelessWidget { this.meta, this.actions, this.appBarActions, + this.appBarTitle, + this.leading, this.backgroundColor, }); @@ -50,6 +52,13 @@ class AlbumDetailHeader extends StatelessWidget { /// Extra toolbar actions (top-right). final List? appBarActions; + /// Toolbar title when it should differ from [title] (e.g. a selection + /// count); defaults to [title]. + final String? appBarTitle; + + /// Replaces the default circular back button. + final Widget? leading; + final Color? backgroundColor; /// Shrinks long titles so up to three lines fit the header. @@ -74,7 +83,7 @@ class AlbumDetailHeader extends StatelessWidget { duration: const Duration(milliseconds: 200), opacity: showTitleInAppBar ? 1.0 : 0.0, child: Text( - title, + appBarTitle ?? title, style: TextStyle( color: colorScheme.onSurface, fontWeight: FontWeight.w600, @@ -198,18 +207,19 @@ class AlbumDetailHeader extends StatelessWidget { ); }, ), - leading: IconButton( - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.4), - shape: BoxShape.circle, + leading: leading ?? + IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.4), + shape: BoxShape.circle, + ), + child: const Icon(Icons.arrow_back, color: Colors.white), + ), + onPressed: () => Navigator.pop(context), ), - child: const Icon(Icons.arrow_back, color: Colors.white), - ), - onPressed: () => Navigator.pop(context), - ), actions: appBarActions, ); }