Sheets rendered at the host's maximum height regardless of content, so a one-item player queue or a two-track picker filled ~75% of a desktop window with empty space. BottomSheetPageScaffold now always lays out Column(mainAxisSize: .min) plus Flexible(child:), and each sheet body shrink-wraps its own scrollable. The scaffold's shrinkWrap flag is gone: its old true branch put the child on an unbounded axis, where an over-tall list overflowed instead of clamping and scrolling. Measured on a 1600x1000 window, the chapter sheet goes from 750px to 118px for one chapter and the two-column track sheet from 750px to 154px for one audio and one subtitle track, both still clamping at the cap. Add SheetSplitColumns for the three side-by-side sheet layouts. A bare VerticalDivider has no intrinsic height, so it inflated those rows to the cap on its own; the rule now paints from a Positioned.fill that cannot size the Stack. IntrinsicHeight is not an option because a Viewport has no intrinsics. Because sheets are bottom-anchored, a content-driven height moves the sheet's top edge and everything above the change point. Three surfaces opt out for that reason and say so at the call site: SubtitleSearchSheet and its language picker keep filling, since both refilter under an autofocused field; FiltersBottomSheet holds the outgoing page's height through its loading transient; and RatingBottomSheet no longer hides MAL/AniList rows asynchronously, which used to slide live rating controls down two rows several hundred ms after open. Wrap the shared StateMessageWidget at the filters sheet boundary rather than editing a widget with 33 filling call sites. The host gains an AnimatedSize keyed per sheet session so nested pushes ease while a replacing show adopts its own height, a 720px absolute height ceiling on desktop windows only, and a min(max(25%, 96px), 60%) drag-dismiss threshold so short sheets neither close on a nudge nor become undismissable. Add videoControls.noAudioDevicesAvailable so the audio output page shows a placeholder instead of a bare header while devices load.
137 lines
5.0 KiB
Dart
137 lines
5.0 KiB
Dart
import 'package:flutter/material.dart';
|
|
import '../../../media/ids.dart';
|
|
import 'package:plezy/widgets/app_icon.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../../i18n/strings.g.dart';
|
|
import '../../../media/media_item.dart';
|
|
import '../../../media/media_item_labels.dart';
|
|
import '../../../media/media_item_types.dart';
|
|
import '../../../providers/playback_state_provider.dart';
|
|
import '../../../services/settings_service.dart';
|
|
import '../../../theme/mono_tokens.dart';
|
|
import '../../../utils/provider_extensions.dart';
|
|
import '../../../utils/scroll_utils.dart';
|
|
import '../../../widgets/focusable_list_tile.dart';
|
|
import '../../../widgets/overlay_sheet.dart';
|
|
import '../../../widgets/settings_builder.dart';
|
|
import '../widgets/media_selector_thumbnail.dart';
|
|
import 'base_video_control_sheet.dart';
|
|
import '../../optimized_media_image.dart';
|
|
|
|
const _kThumbWidth = 60.0;
|
|
const _kThumbHeight = 34.0;
|
|
|
|
/// Bottom sheet for viewing and navigating the play queue
|
|
class QueueSheet extends StatefulWidget {
|
|
final Function(MediaItem) onItemSelected;
|
|
|
|
const QueueSheet({super.key, required this.onItemSelected});
|
|
|
|
@override
|
|
State<QueueSheet> createState() => _QueueSheetState();
|
|
}
|
|
|
|
class _QueueSheetState extends State<QueueSheet> {
|
|
final _initialScroll = InitialItemScrollController();
|
|
|
|
@override
|
|
void dispose() {
|
|
_initialScroll.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SettingValueBuilder<bool>(
|
|
pref: SettingsService.hideSpoilers,
|
|
builder: (context, hideSpoilers, _) => Consumer<PlaybackStateProvider>(
|
|
builder: (context, playbackState, _) {
|
|
final items = playbackState.loadedItems;
|
|
final currentItemID = playbackState.currentPlayQueueItemID;
|
|
|
|
Widget content;
|
|
if (items.isEmpty) {
|
|
content = Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 32),
|
|
child: Center(
|
|
heightFactor: 1,
|
|
child: Text(t.videoControls.noQueueItems, style: TextStyle(color: tokens(context).textMuted)),
|
|
),
|
|
);
|
|
} else {
|
|
final currentIndex = items.indexWhere((item) => playbackState.playQueueItemIdFor(item) == currentItemID);
|
|
_initialScroll.maybeScrollTo(currentIndex);
|
|
|
|
content = ListView.builder(
|
|
shrinkWrap: true,
|
|
controller: _initialScroll.controller,
|
|
itemCount: items.length,
|
|
itemBuilder: (context, index) {
|
|
final item = items[index];
|
|
final isCurrent = playbackState.playQueueItemIdFor(item) == currentItemID;
|
|
|
|
final primaryColor = Theme.of(context).colorScheme.primary;
|
|
return FocusableListTile(
|
|
key: index == 0 ? _initialScroll.firstItemKey : null,
|
|
leading: _buildThumbnail(context, item, isCurrent, hideSpoilers: hideSpoilers),
|
|
title: Text(
|
|
item.title ?? '',
|
|
style: TextStyle(
|
|
color: isCurrent ? primaryColor : null,
|
|
fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal,
|
|
),
|
|
maxLines: 1,
|
|
overflow: .ellipsis,
|
|
),
|
|
subtitle: Text(
|
|
formatQueueItemSubtitle(item),
|
|
style: TextStyle(
|
|
color: isCurrent ? primaryColor.withValues(alpha: 0.7) : tokens(context).textMuted,
|
|
fontSize: 12,
|
|
),
|
|
maxLines: 1,
|
|
overflow: .ellipsis,
|
|
),
|
|
trailing: isCurrent ? AppIcon(Symbols.play_circle_rounded, fill: 1, color: primaryColor) : null,
|
|
onTap: () {
|
|
widget.onItemSelected(item);
|
|
OverlaySheetController.of(context).close();
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
return BaseVideoControlSheet(title: t.videoControls.queue, icon: Symbols.queue_rounded, child: content);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget? _buildThumbnail(BuildContext context, MediaItem item, bool isCurrent, {required bool hideSpoilers}) {
|
|
if (item.thumbPath == null) return null;
|
|
|
|
// Try to get client for thumbnails, may fail in offline mode
|
|
final client = context.tryGetMediaClientForServer(serverIdOrNull(item.serverId));
|
|
|
|
return MediaSelectorThumbnail(
|
|
width: _kThumbWidth,
|
|
height: _kThumbHeight,
|
|
thumbnail: OptimizedMediaImage.thumb(
|
|
client: client,
|
|
imagePath: item.thumbPath,
|
|
width: _kThumbWidth,
|
|
height: _kThumbHeight,
|
|
fit: BoxFit.cover,
|
|
errorWidget: (context, url, error) =>
|
|
AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: _kThumbHeight),
|
|
),
|
|
isCurrent: isCurrent,
|
|
borderColor: Theme.of(context).colorScheme.primary,
|
|
blurThumbnail: hideSpoilers && item.shouldHideSpoiler,
|
|
);
|
|
}
|
|
}
|