Files
plezy/lib/widgets/video_controls/sheets/version_quality_sheet.dart
T
edde746 352b88109b refactor: extract shared mixins and helpers, drop dead abstractions
Introduces shared seams for paginated views, D-pad reorder, media control
routing, async singletons and the device method channel, then points the
open-coded copies at them.

Also removes unused models and duplicated provider/server plumbing, folds
the twice-implemented artifact store in the server, and factors the
repeated Flutter toolchain prologue in CI into a composite action.
2026-07-26 06:09:48 +02:00

246 lines
7.5 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 '../../../widgets/focusable_list_tile.dart';
import '../../../widgets/overlay_sheet.dart';
import 'sheet_selection_column.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: .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 StatelessWidget {
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
Widget build(BuildContext context) {
return SheetSelectionColumn(
headerLabel: showHeader ? t.videoControls.versionColumnHeader : null,
itemCount: versions.length,
initialIndex: selectedIndex,
itemBuilder: (context, index, scope) {
final version = versions[index];
final isSelected = index == selectedIndex;
return _SelectionTile(
key: scope.keyFor(index),
label: version.displayLabel,
isSelected: isSelected,
onTap: () => onSelected(index),
);
},
);
}
}
class _QualityColumn extends StatelessWidget {
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
Widget build(BuildContext context) {
final presets = TranscodeQualityPreset.displayOrder;
return SheetSelectionColumn(
headerLabel: showHeader ? t.videoControls.qualityColumnHeader : null,
itemCount: presets.length,
initialIndex: presets.indexOf(selected),
itemBuilder: (context, index, scope) {
final preset = presets[index];
final isSelected = preset == selected;
final isOriginal = preset.isOriginal;
final enabled = isOriginal || enabledForTranscoding;
final trailing = qualityPresetSizeEstimate(
preset: preset,
sourceBitrateKbps: sourceBitrateKbps,
sourceDurationMs: sourceDurationMs,
sourceSizeBytes: sourceSizeBytes,
);
return _SelectionTile(
key: scope.keyFor(index),
label: qualityPresetLabel(preset),
trailingText: trailing,
isSelected: isSelected,
enabled: enabled,
onTap: enabled ? () => 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: .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,
);
}
}