mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-29 07:18:49 +02:00
feat(tablet): navigation rail, landscape now-playing, clamped track lists
- shell switches to a NavigationRail at >=600dp (same destinations, badges included); the bottom bar and its blur stay phone-only while the mini player remains anchored bottom in both modes - now-playing gains a two-pane landscape layout (artwork left, metadata/controls right) built from the same widget pieces - track lists on album/playlist/library-folder screens and search result sections center at 720dp via wideListInset instead of stretching across the full tablet width; explore carousels stay full-bleed intentionally
This commit is contained in:
@@ -14,6 +14,7 @@ import 'package:spotiflac_android/utils/cover_art_utils.dart';
|
||||
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
|
||||
import 'package:spotiflac_android/widgets/error_card.dart';
|
||||
import 'package:spotiflac_android/widgets/album_detail_header.dart';
|
||||
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
import 'package:spotiflac_android/utils/provider_resource_ids.dart';
|
||||
import 'package:spotiflac_android/utils/ttl_cache.dart';
|
||||
@@ -536,37 +537,40 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
|
||||
),
|
||||
)
|
||||
.maybeWhen(data: (keys) => keys, orElse: () => const <String>{});
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final track = tracks[index];
|
||||
final isInHistory = existingHistoryKeys.contains(
|
||||
historyLookups[index].lookupKey,
|
||||
);
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: StaggeredListItem(
|
||||
index: index,
|
||||
child: TrackListTile(
|
||||
track: track,
|
||||
isInHistory: isInHistory,
|
||||
onDownload: () => _downloadTrack(context, track),
|
||||
clickableArtist: true,
|
||||
leading: SizedBox(
|
||||
width: 32,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${track.trackNumber ?? 0}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
return SliverPadding(
|
||||
padding: EdgeInsets.symmetric(horizontal: wideListInset(context)),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final track = tracks[index];
|
||||
final isInHistory = existingHistoryKeys.contains(
|
||||
historyLookups[index].lookupKey,
|
||||
);
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: StaggeredListItem(
|
||||
index: index,
|
||||
child: TrackListTile(
|
||||
track: track,
|
||||
isInHistory: isInHistory,
|
||||
onDownload: () => _downloadTrack(context, track),
|
||||
clickableArtist: true,
|
||||
leading: SizedBox(
|
||||
width: 32,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${track.trackNumber ?? 0}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}, childCount: tracks.length),
|
||||
);
|
||||
}, childCount: tracks.length),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+29
-22
@@ -24,6 +24,7 @@ import 'package:spotiflac_android/services/csv_import_service.dart';
|
||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
@@ -3168,7 +3169,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
return [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
|
||||
// Aligned with the clamped result list below on wide screens.
|
||||
padding:
|
||||
EdgeInsets.fromLTRB(16, 8, 8, 8) +
|
||||
EdgeInsets.symmetric(horizontal: wideListInset(context)),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -3212,29 +3216,32 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final isFirst = index == 0;
|
||||
final isLast = index == itemCount - 1;
|
||||
return StaggeredListItem(
|
||||
index: index,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: sectionColor,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: isFirst ? const Radius.circular(20) : Radius.zero,
|
||||
bottom: isLast ? const Radius.circular(20) : Radius.zero,
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.symmetric(horizontal: wideListInset(context)),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final isFirst = index == 0;
|
||||
final isLast = index == itemCount - 1;
|
||||
return StaggeredListItem(
|
||||
index: index,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: sectionColor,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: isFirst ? const Radius.circular(20) : Radius.zero,
|
||||
bottom: isLast ? const Radius.circular(20) : Radius.zero,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: itemBuilder(index, !isLast),
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: itemBuilder(index, !isLast),
|
||||
),
|
||||
),
|
||||
);
|
||||
}, childCount: itemCount),
|
||||
);
|
||||
}, childCount: itemCount),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
import 'package:spotiflac_android/utils/cover_art_utils.dart';
|
||||
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
|
||||
@@ -260,35 +261,40 @@ class _LibraryTracksFolderScreenState
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final entry = entries[index];
|
||||
final isSelected = selectedIds.contains(entry.key);
|
||||
final isInHistory = existingHistoryKeys.contains(
|
||||
historyLookups[index].lookupKey,
|
||||
);
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(entry.key),
|
||||
child: StaggeredListItem(
|
||||
index: index,
|
||||
child: _CollectionTrackTile(
|
||||
entry: entry,
|
||||
mode: widget.mode,
|
||||
playlistId: widget.playlistId,
|
||||
folderTracks: folderTracks,
|
||||
isInHistory: isInHistory,
|
||||
isSelectionMode: isSelectionMode,
|
||||
isSelected: isSelected,
|
||||
onTap: isSelectionMode
|
||||
? () => toggleSelection(entry.key)
|
||||
: null,
|
||||
onLongPress: isSelectionMode
|
||||
? null
|
||||
: () => enterSelectionMode(entry.key),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: wideListInset(context),
|
||||
),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final entry = entries[index];
|
||||
final isSelected = selectedIds.contains(entry.key);
|
||||
final isInHistory = existingHistoryKeys.contains(
|
||||
historyLookups[index].lookupKey,
|
||||
);
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(entry.key),
|
||||
child: StaggeredListItem(
|
||||
index: index,
|
||||
child: _CollectionTrackTile(
|
||||
entry: entry,
|
||||
mode: widget.mode,
|
||||
playlistId: widget.playlistId,
|
||||
folderTracks: folderTracks,
|
||||
isInHistory: isInHistory,
|
||||
isSelectionMode: isSelectionMode,
|
||||
isSelected: isSelected,
|
||||
onTap: isSelectionMode
|
||||
? () => toggleSelection(entry.key)
|
||||
: null,
|
||||
onLongPress: isSelectionMode
|
||||
? null
|
||||
: () => enterSelectionMode(entry.key),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}, childCount: entries.length),
|
||||
);
|
||||
}, childCount: entries.length),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: isSelectionMode ? 200 : 32),
|
||||
|
||||
+82
-48
@@ -584,6 +584,39 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
});
|
||||
}
|
||||
|
||||
// Material breakpoint: rail navigation on tablet/landscape widths, the
|
||||
// bottom NavigationBar on phones.
|
||||
final useNavigationRail = MediaQuery.sizeOf(context).width >= 600;
|
||||
|
||||
final pageView = AnimatedBuilder(
|
||||
animation: _tabJumpTransitionController,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: tabs.length,
|
||||
onPageChanged: _onPageChanged,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
// TickerMode mutes animations and lets visibility-aware widgets
|
||||
// (e.g. MotionHeaderBanner) pause when their tab is hidden —
|
||||
// kept-alive pages otherwise keep running offscreen.
|
||||
itemBuilder: (context, index) => _KeepAliveTabPage(
|
||||
key: ValueKey('page-$index'),
|
||||
child: TickerMode(
|
||||
enabled: index == _currentIndex,
|
||||
child: tabs[index],
|
||||
),
|
||||
),
|
||||
),
|
||||
builder: (context, child) {
|
||||
final t = Curves.easeOutCubic.transform(
|
||||
_tabJumpTransitionController.value,
|
||||
);
|
||||
return Opacity(
|
||||
opacity: t,
|
||||
child: Transform.scale(scale: 0.985 + (0.015 * t), child: child),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return BackButtonListener(
|
||||
onBackButtonPressed: () async {
|
||||
await _handleBackPress();
|
||||
@@ -591,34 +624,34 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
},
|
||||
child: Scaffold(
|
||||
extendBody: true,
|
||||
body: AnimatedBuilder(
|
||||
animation: _tabJumpTransitionController,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: tabs.length,
|
||||
onPageChanged: _onPageChanged,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
// TickerMode mutes animations and lets visibility-aware widgets
|
||||
// (e.g. MotionHeaderBanner) pause when their tab is hidden —
|
||||
// kept-alive pages otherwise keep running offscreen.
|
||||
itemBuilder: (context, index) => _KeepAliveTabPage(
|
||||
key: ValueKey('page-$index'),
|
||||
child: TickerMode(
|
||||
enabled: index == _currentIndex,
|
||||
child: tabs[index],
|
||||
),
|
||||
),
|
||||
),
|
||||
builder: (context, child) {
|
||||
final t = Curves.easeOutCubic.transform(
|
||||
_tabJumpTransitionController.value,
|
||||
);
|
||||
return Opacity(
|
||||
opacity: t,
|
||||
child: Transform.scale(scale: 0.985 + (0.015 * t), child: child),
|
||||
);
|
||||
},
|
||||
),
|
||||
body: useNavigationRail
|
||||
? Row(
|
||||
children: [
|
||||
SafeArea(
|
||||
right: false,
|
||||
bottom: false,
|
||||
child: NavigationRail(
|
||||
selectedIndex: _currentIndex.clamp(0, maxIndex),
|
||||
onDestinationSelected: _onNavTap,
|
||||
labelType: NavigationRailLabelType.all,
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainer,
|
||||
destinations: [
|
||||
for (final destination in destinations)
|
||||
NavigationRailDestination(
|
||||
icon: destination.icon,
|
||||
selectedIcon: destination.selectedIcon,
|
||||
label: Text(destination.label),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(child: pageView),
|
||||
],
|
||||
)
|
||||
: pageView,
|
||||
bottomNavigationBar: ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
|
||||
@@ -626,29 +659,30 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const MiniPlayer(),
|
||||
DecoratedBox(
|
||||
position: DecorationPosition.foreground,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
if (!useNavigationRail)
|
||||
DecoratedBox(
|
||||
position: DecorationPosition.foreground,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: NavigationBar(
|
||||
selectedIndex: _currentIndex.clamp(0, maxIndex),
|
||||
onDestinationSelected: _onNavTap,
|
||||
animationDuration: const Duration(milliseconds: 500),
|
||||
elevation: 0,
|
||||
height: 64,
|
||||
backgroundColor: settingsGroupColor(
|
||||
context,
|
||||
).withValues(alpha: 0.72),
|
||||
destinations: destinations,
|
||||
),
|
||||
),
|
||||
child: NavigationBar(
|
||||
selectedIndex: _currentIndex.clamp(0, maxIndex),
|
||||
onDestinationSelected: _onNavTap,
|
||||
animationDuration: const Duration(milliseconds: 500),
|
||||
elevation: 0,
|
||||
height: 64,
|
||||
backgroundColor: settingsGroupColor(
|
||||
context,
|
||||
).withValues(alpha: 0.72),
|
||||
destinations: destinations,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
+199
-142
@@ -417,6 +417,66 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
Widget artworkAt(double artSize) => Center(
|
||||
child: _artworkDragRegion(
|
||||
context,
|
||||
Hero(
|
||||
tag: kNowPlayingArtworkHeroTag,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: SizedBox(
|
||||
width: artSize,
|
||||
height: artSize,
|
||||
child: PlayerArtwork(
|
||||
artUri: mediaItem.artUri?.toString(),
|
||||
colorScheme: colorScheme,
|
||||
cacheWidth:
|
||||
(artSize * MediaQuery.devicePixelRatioOf(context))
|
||||
.round(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Tablet/landscape: artwork pane left, metadata and controls right,
|
||||
// instead of one narrow column in a sea of empty space.
|
||||
final twoPane =
|
||||
constraints.maxWidth >= 720 &&
|
||||
constraints.maxWidth > constraints.maxHeight;
|
||||
if (twoPane) {
|
||||
final artSize = (constraints.maxHeight - 96).clamp(0.0, 420.0);
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: artworkAt(artSize)),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: constraints.maxHeight - 32,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: _metadataAndControls(
|
||||
mediaItem,
|
||||
controller,
|
||||
colorScheme,
|
||||
isPlaying: isPlaying,
|
||||
position: position,
|
||||
duration: duration,
|
||||
posMs: posMs,
|
||||
maxMs: maxMs,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final artSize = (constraints.maxWidth - 64).clamp(0.0, 360.0);
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
@@ -425,149 +485,17 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Center(
|
||||
child: _artworkDragRegion(
|
||||
context,
|
||||
Hero(
|
||||
tag: kNowPlayingArtworkHeroTag,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: SizedBox(
|
||||
width: artSize,
|
||||
height: artSize,
|
||||
child: PlayerArtwork(
|
||||
artUri: mediaItem.artUri?.toString(),
|
||||
colorScheme: colorScheme,
|
||||
cacheWidth:
|
||||
(artSize *
|
||||
MediaQuery.devicePixelRatioOf(context))
|
||||
.round(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
artworkAt(artSize),
|
||||
const SizedBox(height: 32),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
mediaItem.title,
|
||||
style: Theme.of(context).textTheme.headlineSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
mediaItem.artist ?? '',
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
SliderTheme(
|
||||
data: SliderThemeData(
|
||||
trackHeight: 4,
|
||||
activeTrackColor: colorScheme.primary,
|
||||
inactiveTrackColor: colorScheme.onSurface.withValues(
|
||||
alpha: 0.18,
|
||||
),
|
||||
thumbColor: colorScheme.primary,
|
||||
thumbShape: const RoundSliderThumbShape(
|
||||
enabledThumbRadius: 7,
|
||||
),
|
||||
overlayShape: const RoundSliderOverlayShape(
|
||||
overlayRadius: 16,
|
||||
),
|
||||
),
|
||||
child: Slider(
|
||||
value: posMs.clamp(0, maxMs),
|
||||
max: maxMs,
|
||||
onChanged: duration.inMilliseconds > 0
|
||||
? (value) => controller.seek(
|
||||
Duration(milliseconds: value.round()),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
formatClock(position.inSeconds),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: _QualityBadge(
|
||||
label: _qualityLabel(),
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
formatClock(duration.inSeconds),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
iconSize: 44,
|
||||
icon: const Icon(Icons.skip_previous),
|
||||
onPressed: controller.previous,
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
iconSize: 44,
|
||||
padding: const EdgeInsets.all(12),
|
||||
color: colorScheme.onPrimary,
|
||||
icon: Icon(isPlaying ? Icons.pause : Icons.play_arrow),
|
||||
onPressed: () => controller.togglePlayPause(isPlaying),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
IconButton(
|
||||
iconSize: 44,
|
||||
icon: const Icon(Icons.skip_next),
|
||||
onPressed: controller.next,
|
||||
),
|
||||
],
|
||||
..._metadataAndControls(
|
||||
mediaItem,
|
||||
controller,
|
||||
colorScheme,
|
||||
isPlaying: isPlaying,
|
||||
position: position,
|
||||
duration: duration,
|
||||
posMs: posMs,
|
||||
maxMs: maxMs,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -577,6 +505,135 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Title/artist, seek slider, and transport buttons — shared by the
|
||||
/// portrait column and the landscape right pane.
|
||||
List<Widget> _metadataAndControls(
|
||||
MediaItem mediaItem,
|
||||
MusicPlayerController controller,
|
||||
ColorScheme colorScheme, {
|
||||
required bool isPlaying,
|
||||
required Duration position,
|
||||
required Duration duration,
|
||||
required double posMs,
|
||||
required double maxMs,
|
||||
}) {
|
||||
return [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
mediaItem.title,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
mediaItem.artist ?? '',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
SliderTheme(
|
||||
data: SliderThemeData(
|
||||
trackHeight: 4,
|
||||
activeTrackColor: colorScheme.primary,
|
||||
inactiveTrackColor: colorScheme.onSurface.withValues(
|
||||
alpha: 0.18,
|
||||
),
|
||||
thumbColor: colorScheme.primary,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 16),
|
||||
),
|
||||
child: Slider(
|
||||
value: posMs.clamp(0, maxMs),
|
||||
max: maxMs,
|
||||
onChanged: duration.inMilliseconds > 0
|
||||
? (value) =>
|
||||
controller.seek(Duration(milliseconds: value.round()))
|
||||
: null,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
formatClock(position.inSeconds),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: _QualityBadge(
|
||||
label: _qualityLabel(),
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
formatClock(duration.inSeconds),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
iconSize: 44,
|
||||
icon: const Icon(Icons.skip_previous),
|
||||
onPressed: controller.previous,
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
iconSize: 44,
|
||||
padding: const EdgeInsets.all(12),
|
||||
color: colorScheme.onPrimary,
|
||||
icon: Icon(isPlaying ? Icons.pause : Icons.play_arrow),
|
||||
onPressed: () => controller.togglePlayPause(isPlaying),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
IconButton(
|
||||
iconSize: 44,
|
||||
icon: const Icon(Icons.skip_next),
|
||||
onPressed: controller.next,
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _lyricsSection(ColorScheme colorScheme) {
|
||||
if (_loadingMeta) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
||||
import 'package:spotiflac_android/utils/cover_art_utils.dart';
|
||||
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
import 'package:spotiflac_android/utils/provider_resource_ids.dart';
|
||||
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
||||
@@ -307,46 +308,49 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
|
||||
),
|
||||
)
|
||||
.maybeWhen(data: (keys) => keys, orElse: () => const <String>{});
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final track = _tracks[index];
|
||||
final isInHistory = existingHistoryKeys.contains(
|
||||
historyLookups[index].lookupKey,
|
||||
);
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: StaggeredListItem(
|
||||
index: index,
|
||||
child: TrackListTile(
|
||||
track: track,
|
||||
isInHistory: isInHistory,
|
||||
onDownload: () =>
|
||||
_downloadTrack(context, track, playlistPosition: index + 1),
|
||||
leading: track.coverUrl != null
|
||||
? CachedCoverImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
)
|
||||
: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
return SliverPadding(
|
||||
padding: EdgeInsets.symmetric(horizontal: wideListInset(context)),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final track = _tracks[index];
|
||||
final isInHistory = existingHistoryKeys.contains(
|
||||
historyLookups[index].lookupKey,
|
||||
);
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: StaggeredListItem(
|
||||
index: index,
|
||||
child: TrackListTile(
|
||||
track: track,
|
||||
isInHistory: isInHistory,
|
||||
onDownload: () =>
|
||||
_downloadTrack(context, track, playlistPosition: index + 1),
|
||||
leading: track.coverUrl != null
|
||||
? CachedCoverImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
)
|
||||
: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}, childCount: _tracks.length),
|
||||
);
|
||||
}, childCount: _tracks.length),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Horizontal inset that centers full-width list content at [contentMaxWidth]
|
||||
/// on tablets/landscape; zero on phones, so rows stop stretching across the
|
||||
/// whole screen.
|
||||
double wideListInset(BuildContext context, {double contentMaxWidth = 720}) {
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
return width > contentMaxWidth ? (width - contentMaxWidth) / 2 : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user