Files
plezy/lib/widgets/video_controls/sheets/version_quality_sheet.dart
T

290 lines
8.8 KiB
Dart

import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/widgets/app_icon.dart';
import '../../../i18n/strings.g.dart';
import '../../../media/media_version.dart';
import '../../../models/transcode_quality_preset.dart';
import '../../../utils/quality_preset_labels.dart';
import '../../../utils/scroll_utils.dart';
import '../../../widgets/focusable_list_tile.dart';
import '../../../widgets/overlay_sheet.dart';
import 'sheet_column_header.dart';
String versionQualityPickerTitle({required bool showVersions, required bool showQuality}) {
return showQuality
? (showVersions ? t.videoControls.versionQualityButton : t.videoControls.qualityColumnHeader)
: t.videoControls.versionColumnHeader;
}
/// Combined picker for selecting the media [version] (left) and transcode
/// [quality] preset (right). The version column is hidden when there is only
/// one version so the quality list gets the full width. If the server doesn't
/// support video transcoding, the quality column is hidden entirely.
class VersionQualityPicker extends StatelessWidget {
final List<MediaVersion> availableVersions;
final int selectedMediaIndex;
final TranscodeQualityPreset selectedQualityPreset;
final bool serverSupportsTranscoding;
final int? sourceDurationMs;
final ValueChanged<int> onVersionSelected;
final ValueChanged<TranscodeQualityPreset> onQualitySelected;
const VersionQualityPicker({
super.key,
required this.availableVersions,
required this.selectedMediaIndex,
required this.selectedQualityPreset,
required this.serverSupportsTranscoding,
required this.onVersionSelected,
required this.onQualitySelected,
this.sourceDurationMs,
});
@override
Widget build(BuildContext context) {
final showVersions = availableVersions.length > 1;
final showQuality = serverSupportsTranscoding;
final qualityColumn = FocusTraversalGroup(
child: _QualityColumn(
selected: selectedQualityPreset,
enabledForTranscoding: serverSupportsTranscoding,
sourceBitrateKbps: _sourceBitrateKbps(),
sourceDurationMs: sourceDurationMs,
sourceSizeBytes: _sourceSizeBytes(),
onSelected: (preset) {
OverlaySheetController.of(context).close();
onQualitySelected(preset);
},
showHeader: showVersions,
),
);
final versionColumn = FocusTraversalGroup(
child: _VersionColumn(
versions: availableVersions,
selectedIndex: selectedMediaIndex,
onSelected: (index) {
OverlaySheetController.of(context).close();
onVersionSelected(index);
},
showHeader: showQuality,
),
);
if (showVersions && showQuality) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: versionColumn),
VerticalDivider(width: 1, color: Theme.of(context).dividerColor),
Expanded(child: qualityColumn),
],
);
} else if (showVersions) {
return versionColumn;
} else {
return qualityColumn;
}
}
int? _sourceBitrateKbps() {
if (selectedMediaIndex < 0 || selectedMediaIndex >= availableVersions.length) {
return null;
}
final b = availableVersions[selectedMediaIndex].bitrate;
if (b == null || b <= 0) return null;
return b;
}
int? _sourceSizeBytes() {
if (selectedMediaIndex < 0 || selectedMediaIndex >= availableVersions.length) {
return null;
}
final parts = availableVersions[selectedMediaIndex].parts;
if (parts.isEmpty) return null;
var total = 0;
for (final p in parts) {
final s = p.sizeBytes;
if (s == null || s <= 0) return null;
total += s;
}
return total > 0 ? total : null;
}
}
class _VersionColumn extends StatefulWidget {
final List<MediaVersion> versions;
final int selectedIndex;
final ValueChanged<int> onSelected;
final bool showHeader;
const _VersionColumn({
required this.versions,
required this.selectedIndex,
required this.onSelected,
required this.showHeader,
});
@override
State<_VersionColumn> createState() => _VersionColumnState();
}
class _VersionColumnState extends State<_VersionColumn> {
final _initialScroll = InitialItemScrollController();
@override
void dispose() {
_initialScroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
_initialScroll.maybeScrollTo(widget.selectedIndex);
return Column(
children: [
if (widget.showHeader) SheetColumnHeader(label: t.videoControls.versionColumnHeader),
Expanded(
child: ListView.builder(
controller: _initialScroll.controller,
itemCount: widget.versions.length,
itemBuilder: (context, index) {
final version = widget.versions[index];
final isSelected = index == widget.selectedIndex;
return _SelectionTile(
key: index == 0 ? _initialScroll.firstItemKey : null,
label: version.displayLabel,
isSelected: isSelected,
onTap: () => widget.onSelected(index),
);
},
),
),
],
);
}
}
class _QualityColumn extends StatefulWidget {
final TranscodeQualityPreset selected;
final bool enabledForTranscoding;
final int? sourceBitrateKbps;
final int? sourceDurationMs;
final int? sourceSizeBytes;
final ValueChanged<TranscodeQualityPreset> onSelected;
final bool showHeader;
const _QualityColumn({
required this.selected,
required this.enabledForTranscoding,
required this.sourceBitrateKbps,
required this.sourceDurationMs,
required this.sourceSizeBytes,
required this.onSelected,
required this.showHeader,
});
@override
State<_QualityColumn> createState() => _QualityColumnState();
}
class _QualityColumnState extends State<_QualityColumn> {
final _initialScroll = InitialItemScrollController();
@override
void dispose() {
_initialScroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final presets = TranscodeQualityPreset.displayOrder;
final selectedIndex = presets.indexOf(widget.selected);
_initialScroll.maybeScrollTo(selectedIndex);
return Column(
children: [
if (widget.showHeader) SheetColumnHeader(label: t.videoControls.qualityColumnHeader),
Expanded(
child: ListView.builder(
controller: _initialScroll.controller,
itemCount: presets.length,
itemBuilder: (context, index) {
final preset = presets[index];
final isSelected = preset == widget.selected;
final isOriginal = preset.isOriginal;
final enabled = isOriginal || widget.enabledForTranscoding;
final trailing = qualityPresetSizeEstimate(
preset: preset,
sourceBitrateKbps: widget.sourceBitrateKbps,
sourceDurationMs: widget.sourceDurationMs,
sourceSizeBytes: widget.sourceSizeBytes,
);
return _SelectionTile(
key: index == 0 ? _initialScroll.firstItemKey : null,
label: qualityPresetLabel(preset),
trailingText: trailing,
isSelected: isSelected,
enabled: enabled,
onTap: enabled ? () => widget.onSelected(preset) : null,
);
},
),
),
],
);
}
}
class _SelectionTile extends StatelessWidget {
final String label;
final String? trailingText;
final bool isSelected;
final bool enabled;
final VoidCallback? onTap;
const _SelectionTile({
super.key,
required this.label,
this.trailingText,
required this.isSelected,
this.enabled = true,
this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final primary = scheme.primary;
final disabledColor = scheme.onSurface.withValues(alpha: 0.38);
final titleColor = !enabled ? disabledColor : (isSelected ? primary : null);
final trailingColor = !enabled ? disabledColor : scheme.onSurfaceVariant;
final hasText = trailingText != null && trailingText!.isNotEmpty;
final trailing = (hasText || isSelected)
? Row(
mainAxisSize: MainAxisSize.min,
children: [
if (hasText) Text(trailingText!, style: TextStyle(color: trailingColor)),
if (hasText && isSelected) const SizedBox(width: 8),
if (isSelected) AppIcon(Symbols.check_rounded, fill: 1, color: primary),
],
)
: null;
return FocusableListTile(
title: Text(label, style: TextStyle(color: titleColor)),
trailing: trailing,
enabled: enabled,
onTap: onTap,
);
}
}