refactor(player): move quality picker into playback settings

This commit is contained in:
edde746
2026-05-08 06:24:59 +02:00
parent 65b42bca3f
commit 653fada5dc
3 changed files with 95 additions and 70 deletions
@@ -9,15 +9,19 @@ import '../../../utils/quality_preset_labels.dart';
import '../../../utils/scroll_utils.dart';
import '../../../widgets/focusable_list_tile.dart';
import '../../../widgets/overlay_sheet.dart';
import 'base_video_control_sheet.dart';
import 'sheet_column_header.dart';
/// Combined sheet for selecting the media [version] (left) and transcode
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, only [TranscodeQualityPreset.original] is
/// enabled in the quality column.
class VersionQualitySheet extends StatelessWidget {
/// support video transcoding, the quality column is hidden entirely.
class VersionQualityPicker extends StatelessWidget {
final List<MediaVersion> availableVersions;
final int selectedMediaIndex;
final TranscodeQualityPreset selectedQualityPreset;
@@ -26,7 +30,7 @@ class VersionQualitySheet extends StatelessWidget {
final ValueChanged<int> onVersionSelected;
final ValueChanged<TranscodeQualityPreset> onQualitySelected;
const VersionQualitySheet({
const VersionQualityPicker({
super.key,
required this.availableVersions,
required this.selectedMediaIndex,
@@ -40,14 +44,7 @@ class VersionQualitySheet extends StatelessWidget {
@override
Widget build(BuildContext context) {
final showVersions = availableVersions.length > 1;
// Quality presets only do something useful when the server can transcode
// — otherwise non-Original options are disabled and the column degenerates
// into a single tappable row. Hide it entirely in that case so the
// versions list (when present) gets the full sheet.
final showQuality = serverSupportsTranscoding;
final title = showQuality
? (showVersions ? t.videoControls.versionQualityButton : t.videoControls.qualityColumnHeader)
: t.videoControls.versionColumnHeader;
final qualityColumn = FocusTraversalGroup(
child: _QualityColumn(
@@ -76,9 +73,8 @@ class VersionQualitySheet extends StatelessWidget {
),
);
final Widget body;
if (showVersions && showQuality) {
body = Row(
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: versionColumn),
@@ -87,12 +83,10 @@ class VersionQualitySheet extends StatelessWidget {
],
);
} else if (showVersions) {
body = versionColumn;
return versionColumn;
} else {
body = qualityColumn;
return qualityColumn;
}
return BaseVideoControlSheet(title: title, icon: Symbols.video_settings_rounded, child: body);
}
int? _sourceBitrateKbps() {
@@ -12,6 +12,8 @@ import 'package:path/path.dart' as path;
import 'package:provider/provider.dart';
import '../../../models/shader_preset.dart';
import '../../../models/transcode_quality_preset.dart';
import '../../../media/media_version.dart';
import '../../../mpv/mpv.dart';
import '../../../providers/shader_provider.dart';
import '../../../services/file_picker_service.dart';
@@ -22,6 +24,7 @@ import '../../../focus/focusable_wrapper.dart';
import '../../../utils/dialogs.dart';
import '../../../utils/formatters.dart';
import '../../../utils/platform_detector.dart';
import '../../../utils/quality_preset_labels.dart';
import '../../../utils/snackbar_helper.dart';
import '../../../theme/mono_tokens.dart';
import '../../../widgets/focusable_list_tile.dart';
@@ -30,8 +33,9 @@ import '../widgets/sync_offset_control.dart';
import '../widgets/sleep_timer_content.dart';
import '../../../i18n/strings.g.dart';
import 'base_video_control_sheet.dart';
import 'version_quality_sheet.dart';
enum _SettingsView { menu, speed, sleep, audioSync, subtitleSync, audioDevice, shader, dvConversion }
enum _SettingsView { menu, speed, versionQuality, sleep, audioSync, subtitleSync, audioDevice, shader, dvConversion }
class _SettingsMenuItem extends StatelessWidget {
final IconData icon;
@@ -118,6 +122,15 @@ class VideoSettingsSheet extends StatefulWidget {
/// Whether this is a live TV stream (hides speed settings).
final bool isLive;
/// Available media versions and quality controls shown inside playback settings.
final List<MediaVersion> availableVersions;
final int selectedMediaIndex;
final TranscodeQualityPreset selectedQualityPreset;
final bool serverSupportsTranscoding;
final int? sourceDurationMs;
final ValueChanged<int>? onVersionSelected;
final ValueChanged<TranscodeQualityPreset>? onQualitySelected;
/// Optional shader service for MPV shader control
final ShaderService? shaderService;
@@ -146,6 +159,13 @@ class VideoSettingsSheet extends StatefulWidget {
required this.subtitleSyncOffset,
this.canControl = true,
this.isLive = false,
this.availableVersions = const [],
this.selectedMediaIndex = 0,
this.selectedQualityPreset = TranscodeQualityPreset.original,
this.serverSupportsTranscoding = false,
this.sourceDurationMs,
this.onVersionSelected,
this.onQualitySelected,
this.shaderService,
this.onShaderChanged,
this.isAmbientLightingEnabled = false,
@@ -266,6 +286,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
return t.videoSettings.playbackSettings;
case _SettingsView.speed:
return t.videoSettings.playbackSpeed;
case _SettingsView.versionQuality:
return _versionQualityTitle();
case _SettingsView.sleep:
return t.videoSettings.sleepTimer;
case _SettingsView.audioSync:
@@ -287,6 +309,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
return Symbols.tune_rounded;
case _SettingsView.speed:
return Symbols.speed_rounded;
case _SettingsView.versionQuality:
return Symbols.art_track;
case _SettingsView.sleep:
return Symbols.bedtime_rounded;
case _SettingsView.audioSync:
@@ -327,6 +351,33 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
return 'Active (${formatDurationWithSeconds(remaining)})';
}
bool get _hasVersionQuality {
return (widget.availableVersions.length > 1 || widget.serverSupportsTranscoding) &&
(widget.onVersionSelected != null || widget.onQualitySelected != null);
}
String _versionQualityTitle() {
return versionQualityPickerTitle(
showVersions: widget.availableVersions.length > 1,
showQuality: widget.serverSupportsTranscoding,
);
}
String _versionQualityValueText() {
final values = <String>[];
if (widget.availableVersions.length > 1) values.add(_selectedVersionLabel());
if (widget.serverSupportsTranscoding) values.add(qualityPresetLabel(widget.selectedQualityPreset));
return values.join(' / ');
}
String _selectedVersionLabel() {
final index = widget.selectedMediaIndex;
if (index >= 0 && index < widget.availableVersions.length) {
return widget.availableVersions[index].displayLabel;
}
return t.videoControls.versionColumnHeader;
}
Widget _buildMenuView() {
final sleepTimer = SleepTimerService();
final isDesktop = PlatformDetector.isDesktop(context);
@@ -349,6 +400,15 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
},
),
if (_hasVersionQuality)
_SettingsMenuItem(
icon: Symbols.art_track,
title: _versionQualityTitle(),
valueText: _versionQualityValueText(),
allowValueOverflow: true,
onTap: () => _navigateTo(_SettingsView.versionQuality),
),
// Sleep Timer
ListenableBuilder(
listenable: sleepTimer,
@@ -577,6 +637,18 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
);
}
Widget _buildVersionQualityView() {
return VersionQualityPicker(
availableVersions: widget.availableVersions,
selectedMediaIndex: widget.selectedMediaIndex,
selectedQualityPreset: widget.selectedQualityPreset,
serverSupportsTranscoding: widget.serverSupportsTranscoding,
sourceDurationMs: widget.sourceDurationMs,
onVersionSelected: (index) => widget.onVersionSelected?.call(index),
onQualitySelected: (preset) => widget.onQualitySelected?.call(preset),
);
}
/// Extract the audio backend name from a device name (e.g. "coreaudio" from "coreaudio/BuiltIn").
static String _audioBackend(String name) {
final slash = name.indexOf('/');
@@ -833,6 +905,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
return _buildMenuView();
case _SettingsView.speed:
return _buildSpeedView();
case _SettingsView.versionQuality:
return _buildVersionQualityView();
case _SettingsView.sleep:
return _buildSleepView();
case _SettingsView.audioSync:
@@ -16,7 +16,6 @@ import '../../../models/transcode_quality_preset.dart';
import '../sheets/chapter_sheet.dart';
import '../sheets/queue_sheet.dart';
import '../sheets/track_sheet.dart';
import '../sheets/version_quality_sheet.dart';
import '../sheets/video_settings_sheet.dart';
import '../../../services/shader_service.dart';
import '../helpers/track_filter_helper.dart';
@@ -217,6 +216,13 @@ class TrackChapterControls extends StatelessWidget {
subtitleSyncOffset: subtitleSyncOffset,
canControl: canControl,
isLive: isLive,
availableVersions: availableVersions,
selectedMediaIndex: selectedMediaIndex,
selectedQualityPreset: selectedQualityPreset,
serverSupportsTranscoding: serverSupportsTranscoding,
sourceDurationMs: trackControlsState.sourceDurationMs,
onVersionSelected: onSwitchVersion == null ? null : (i) => onSwitchVersion!(i),
onQualitySelected: onSwitchQualityPreset,
shaderService: shaderService,
onShaderChanged: onShaderChanged,
isAmbientLightingEnabled: isAmbientLightingEnabled,
@@ -337,51 +343,6 @@ class TrackChapterControls extends StatelessWidget {
buttonIndex++;
}
// Version & Quality button
final showVersionQuality =
(availableVersions.length > 1 || serverSupportsTranscoding) &&
(onSwitchVersion != null || onSwitchQualityPreset != null);
if (showVersionQuality) {
final currentIndex = buttonIndex;
// Tooltip narrows to whichever column the sheet will actually
// render — Jellyfin items only show the version list, so calling
// the button "Version & Quality" implies a quality picker that
// isn't there.
final buttonLabel = serverSupportsTranscoding
? (availableVersions.length > 1
? t.videoControls.versionQualityButton
: t.videoControls.qualityColumnHeader)
: t.videoControls.versionColumnHeader;
buttons.add(
_buildTrackButton(
buttonIndex: currentIndex,
icon: Symbols.video_settings_rounded,
tooltip: buttonLabel,
semanticLabel: buttonLabel,
tracks: tracks,
isMobile: isMobile,
isDesktop: isDesktop,
onPressed: () {
onCancelAutoHide?.call();
OverlaySheetController.of(context)
.show(
builder: (_) => VersionQualitySheet(
availableVersions: availableVersions,
selectedMediaIndex: selectedMediaIndex,
selectedQualityPreset: selectedQualityPreset,
serverSupportsTranscoding: serverSupportsTranscoding,
sourceDurationMs: trackControlsState.sourceDurationMs,
onVersionSelected: (i) => onSwitchVersion?.call(i),
onQualitySelected: (p) => onSwitchQualityPreset?.call(p),
),
)
.whenComplete(() => onStartAutoHide?.call());
},
),
);
buttonIndex++;
}
// Picture-in-Picture mode
if (onTogglePIPMode != null) {
final currentIndex = buttonIndex;
@@ -503,10 +464,6 @@ class TrackChapterControls extends StatelessWidget {
count++; // Audio & subtitles button always shown
if (chapters.isNotEmpty && !hideChaptersAndQueue) count++;
if (showQueueButton && onQueueItemSelected != null && !hideChaptersAndQueue) count++;
if ((availableVersions.length > 1 || serverSupportsTranscoding) &&
(onSwitchVersion != null || onSwitchQualityPreset != null)) {
count++;
}
if (onTogglePIPMode != null) count++;
if (onCycleBoxFitMode != null) count++;
if (isMobile && !PlatformDetector.isTV()) count++; // Rotation lock (not on TV)