Files
plezy/lib/widgets/video_controls/widgets/track_chapter_controls.dart
T

478 lines
18 KiB
Dart

import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:flutter/services.dart';
import '../../../focus/dpad_navigator.dart';
import '../../../mpv/mpv.dart';
import '../../../models/plex_media_info.dart';
import '../../../models/plex_media_version.dart';
import '../../../services/sleep_timer_service.dart';
import '../../../utils/platform_detector.dart';
import '../../../i18n/strings.g.dart';
import '../../../widgets/overlay_sheet.dart';
import '../../../models/plex_metadata.dart';
import '../models/track_controls_state.dart';
import '../sheets/chapter_sheet.dart';
import '../sheets/queue_sheet.dart';
import '../sheets/track_sheet.dart';
import '../sheets/version_sheet.dart';
import '../sheets/video_settings_sheet.dart';
import '../../../services/shader_service.dart';
import '../helpers/track_filter_helper.dart';
import '../video_control_button.dart';
/// Row of track and chapter control buttons for the video player
class TrackChapterControls extends StatelessWidget {
final Player player;
final List<PlexChapter> chapters;
final bool chaptersLoaded;
final TrackControlsState trackControlsState;
/// List of FocusNodes for the buttons (passed from parent for navigation)
final List<FocusNode>? focusNodes;
/// Called when focus changes on any button
final ValueChanged<bool>? onFocusChange;
/// Called to navigate left from the first button
final VoidCallback? onNavigateLeft;
/// Whether to hide the chapters and queue buttons (mobile uses content strip instead)
final bool hideChaptersAndQueue;
const TrackChapterControls({
super.key,
required this.player,
required this.chapters,
required this.chaptersLoaded,
required this.trackControlsState,
this.focusNodes,
this.onFocusChange,
this.onNavigateLeft,
this.hideChaptersAndQueue = false,
});
List<PlexMediaVersion> get availableVersions => trackControlsState.availableVersions;
int get selectedMediaIndex => trackControlsState.selectedMediaIndex;
int get boxFitMode => trackControlsState.boxFitMode;
int get audioSyncOffset => trackControlsState.audioSyncOffset;
int get subtitleSyncOffset => trackControlsState.subtitleSyncOffset;
bool get isRotationLocked => trackControlsState.isRotationLocked;
bool get isFullscreen => trackControlsState.isFullscreen;
bool get isAlwaysOnTop => trackControlsState.isAlwaysOnTop;
VoidCallback? get onTogglePIPMode => trackControlsState.onTogglePIPMode;
VoidCallback? get onCycleBoxFitMode => trackControlsState.onCycleBoxFitMode;
VoidCallback? get onToggleRotationLock => trackControlsState.onToggleRotationLock;
VoidCallback? get onToggleFullscreen => trackControlsState.onToggleFullscreen;
VoidCallback? get onToggleAlwaysOnTop => trackControlsState.onToggleAlwaysOnTop;
Function(int)? get onSwitchVersion => trackControlsState.onSwitchVersion;
Function(AudioTrack)? get onAudioTrackChanged => trackControlsState.onAudioTrackChanged;
Function(SubtitleTrack)? get onSubtitleTrackChanged => trackControlsState.onSubtitleTrackChanged;
Function(SubtitleTrack)? get onSecondarySubtitleTrackChanged => trackControlsState.onSecondarySubtitleTrackChanged;
VoidCallback? get onLoadSeekTimes => trackControlsState.onLoadSeekTimes;
VoidCallback? get onCancelAutoHide => trackControlsState.onCancelAutoHide;
VoidCallback? get onStartAutoHide => trackControlsState.onStartAutoHide;
void Function(String propertyName, int offset)? get onSyncOffsetChanged => trackControlsState.onSyncOffsetChanged;
String get serverId => trackControlsState.serverId;
ShaderService? get shaderService => trackControlsState.shaderService;
VoidCallback? get onShaderChanged => trackControlsState.onShaderChanged;
bool get isAmbientLightingEnabled => trackControlsState.isAmbientLightingEnabled;
VoidCallback? get onToggleAmbientLighting => trackControlsState.onToggleAmbientLighting;
bool get canControl => trackControlsState.canControl;
bool get isLive => trackControlsState.isLive;
bool get subtitlesVisible => trackControlsState.subtitlesVisible;
bool get showQueueButton => trackControlsState.showQueueButton;
Function(PlexMetadata)? get onQueueItemSelected => trackControlsState.onQueueItemSelected;
/// Handle key event for button navigation
KeyEventResult _handleButtonKeyEvent(FocusNode _, KeyEvent event, int index, int totalButtons) {
if (!event.isActionable) {
return KeyEventResult.ignored;
}
final key = event.logicalKey;
// LEFT arrow - move to previous button or exit to volume
if (key == LogicalKeyboardKey.arrowLeft) {
if (index > 0 && focusNodes != null && focusNodes!.length > index - 1) {
focusNodes![index - 1].requestFocus();
return KeyEventResult.handled;
} else if (index == 0) {
onNavigateLeft?.call();
return KeyEventResult.handled;
}
return KeyEventResult.handled;
}
// RIGHT arrow - move to next button
if (key == LogicalKeyboardKey.arrowRight) {
if (index < totalButtons - 1 && focusNodes != null && focusNodes!.length > index + 1) {
focusNodes![index + 1].requestFocus();
return KeyEventResult.handled;
}
// At end, consume to prevent bubbling
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
/// Build a track control button with consistent focus handling
Widget _buildTrackButton({
required int buttonIndex,
required IconData icon,
required String semanticLabel,
required VoidCallback? onPressed,
required Tracks? tracks,
required bool isMobile,
required bool isDesktop,
String? tooltip,
bool isActive = false,
}) {
return VideoControlButton(
icon: icon,
tooltip: tooltip,
semanticLabel: semanticLabel,
isActive: isActive,
focusNode: focusNodes != null && focusNodes!.length > buttonIndex ? focusNodes![buttonIndex] : null,
onKeyEvent: focusNodes != null
? (node, event) =>
_handleButtonKeyEvent(node, event, buttonIndex, _getButtonCount(tracks, isMobile, isDesktop))
: null,
onFocusChange: onFocusChange,
onPressed: onPressed,
);
}
@override
Widget build(BuildContext context) {
return StreamBuilder<Tracks>(
stream: player.streams.tracks,
initialData: player.state.tracks,
builder: (context, snapshot) {
final tracks = snapshot.data;
final isMobile = PlatformDetector.isMobile(context);
final isDesktop = Platform.isWindows || Platform.isLinux || Platform.isMacOS;
// Build list of buttons dynamically to track indices
final buttons = <Widget>[];
int buttonIndex = 0;
// Settings button (always shown)
buttons.add(
ListenableBuilder(
listenable: SleepTimerService(),
builder: (context, _) {
final sleepTimer = SleepTimerService();
final isShaderActive =
shaderService != null && shaderService!.isSupported && shaderService!.currentPreset.isEnabled;
final isActive = sleepTimer.isActive || audioSyncOffset != 0 || subtitleSyncOffset != 0 || isShaderActive;
return _buildTrackButton(
buttonIndex: 0,
icon: Symbols.tune_rounded,
isActive: isActive,
tooltip: t.videoControls.settingsButton,
semanticLabel: t.videoControls.settingsButton,
tracks: tracks,
isMobile: isMobile,
isDesktop: isDesktop,
onPressed: () {
onCancelAutoHide?.call();
OverlaySheetController.of(context)
.show(
builder: (_) => VideoSettingsSheet(
player: player,
audioSyncOffset: audioSyncOffset,
subtitleSyncOffset: subtitleSyncOffset,
canControl: canControl,
isLive: isLive,
shaderService: shaderService,
onShaderChanged: onShaderChanged,
isAmbientLightingEnabled: isAmbientLightingEnabled,
onToggleAmbientLighting: onToggleAmbientLighting,
onCancelAutoHide: onCancelAutoHide,
onStartAutoHide: onStartAutoHide,
onSyncOffsetChanged: onSyncOffsetChanged,
),
)
.whenComplete(() {
onStartAutoHide?.call();
onLoadSeekTimes?.call();
});
},
);
},
),
);
buttonIndex++;
// Combined audio & subtitles button
if (_hasMultipleAudioTracks(tracks) || _hasSubtitles(tracks)) {
final currentIndex = buttonIndex;
final hasSubs = _hasSubtitles(tracks);
final selectedSub = player.state.track.subtitle;
final hasActiveSubtitle = selectedSub != null && selectedSub.id != 'no';
final isHidden = hasSubs && hasActiveSubtitle && !subtitlesVisible;
final icon = hasSubs
? (isHidden ? Symbols.subtitles_off_rounded : Symbols.subtitles_rounded)
: Symbols.audiotrack_rounded;
buttons.add(
_buildTrackButton(
buttonIndex: currentIndex,
icon: icon,
tooltip: t.videoControls.tracksButton,
semanticLabel: t.videoControls.tracksButton,
tracks: tracks,
isMobile: isMobile,
isDesktop: isDesktop,
onPressed: () {
onCancelAutoHide?.call();
OverlaySheetController.of(context)
.show(
builder: (_) => TrackSheet(
player: player,
onAudioTrackChanged: onAudioTrackChanged,
onSubtitleTrackChanged: onSubtitleTrackChanged,
onSecondarySubtitleTrackChanged: onSecondarySubtitleTrackChanged,
),
)
.whenComplete(() => onStartAutoHide?.call());
},
),
);
buttonIndex++;
}
// Chapters button (hidden on mobile when content strip is available)
if (chapters.isNotEmpty && !hideChaptersAndQueue) {
final currentIndex = buttonIndex;
buttons.add(
_buildTrackButton(
buttonIndex: currentIndex,
icon: Symbols.video_library_rounded,
tooltip: t.videoControls.chaptersButton,
semanticLabel: t.videoControls.chaptersButton,
tracks: tracks,
isMobile: isMobile,
isDesktop: isDesktop,
onPressed: () {
onCancelAutoHide?.call();
OverlaySheetController.of(context)
.show(
builder: (_) => ChapterSheet(
player: player,
chapters: chapters,
chaptersLoaded: chaptersLoaded,
serverId: serverId,
),
)
.whenComplete(() => onStartAutoHide?.call());
},
),
);
buttonIndex++;
}
// Queue button (hidden on mobile when content strip is available)
if (showQueueButton && onQueueItemSelected != null && !hideChaptersAndQueue) {
final currentIndex = buttonIndex;
buttons.add(
_buildTrackButton(
buttonIndex: currentIndex,
icon: Symbols.queue_music_rounded,
tooltip: t.videoControls.queue,
semanticLabel: t.videoControls.queue,
tracks: tracks,
isMobile: isMobile,
isDesktop: isDesktop,
onPressed: () {
onCancelAutoHide?.call();
OverlaySheetController.of(context)
.show(builder: (_) => QueueSheet(onItemSelected: onQueueItemSelected!))
.whenComplete(() => onStartAutoHide?.call());
},
),
);
buttonIndex++;
}
// Versions button
if (availableVersions.length > 1 && onSwitchVersion != null) {
final currentIndex = buttonIndex;
buttons.add(
_buildTrackButton(
buttonIndex: currentIndex,
icon: Symbols.video_file_rounded,
tooltip: t.videoControls.versionsButton,
semanticLabel: t.videoControls.versionsButton,
tracks: tracks,
isMobile: isMobile,
isDesktop: isDesktop,
onPressed: () {
onCancelAutoHide?.call();
OverlaySheetController.of(context)
.show(
builder: (_) => VersionSheet(
availableVersions: availableVersions,
selectedMediaIndex: selectedMediaIndex,
onVersionSelected: onSwitchVersion!,
),
)
.whenComplete(() => onStartAutoHide?.call());
},
),
);
buttonIndex++;
}
// Picture-in-Picture mode
if (onTogglePIPMode != null) {
final currentIndex = buttonIndex;
buttons.add(
_buildTrackButton(
buttonIndex: currentIndex,
icon: Symbols.picture_in_picture_alt,
tooltip: t.videoControls.pipButton,
semanticLabel: t.videoControls.pipButton,
tracks: tracks,
isMobile: isMobile,
isDesktop: isDesktop,
onPressed: onTogglePIPMode,
),
);
buttonIndex++;
}
// BoxFit mode button
if (onCycleBoxFitMode != null) {
final currentIndex = buttonIndex;
buttons.add(
_buildTrackButton(
buttonIndex: currentIndex,
icon: _getBoxFitIcon(boxFitMode),
tooltip: _getBoxFitTooltip(boxFitMode),
semanticLabel: t.videoControls.aspectRatioButton,
tracks: tracks,
isMobile: isMobile,
isDesktop: isDesktop,
onPressed: onCycleBoxFitMode,
),
);
buttonIndex++;
}
// Rotation lock button (mobile only, not on TV since screens don't rotate)
if (isMobile && !PlatformDetector.isTV()) {
final currentIndex = buttonIndex;
buttons.add(
_buildTrackButton(
buttonIndex: currentIndex,
icon: isRotationLocked ? Symbols.screen_lock_rotation_rounded : Symbols.screen_rotation_rounded,
tooltip: isRotationLocked ? t.videoControls.unlockRotation : t.videoControls.lockRotation,
semanticLabel: t.videoControls.rotationLockButton,
tracks: tracks,
isMobile: isMobile,
isDesktop: isDesktop,
onPressed: onToggleRotationLock,
),
);
buttonIndex++;
}
// Always on top button (desktop only, not TV)
if (isDesktop && onToggleAlwaysOnTop != null) {
final currentIndex = buttonIndex;
buttons.add(
_buildTrackButton(
buttonIndex: currentIndex,
icon: Symbols.layers_rounded,
tooltip: t.videoControls.alwaysOnTopButton,
semanticLabel: t.videoControls.alwaysOnTopButton,
isActive: isAlwaysOnTop,
tracks: tracks,
isMobile: isMobile,
isDesktop: isDesktop,
onPressed: onToggleAlwaysOnTop,
),
);
buttonIndex++;
}
// Fullscreen button (desktop only)
if (isDesktop) {
final currentIndex = buttonIndex;
buttons.add(
_buildTrackButton(
buttonIndex: currentIndex,
icon: isFullscreen ? Symbols.fullscreen_exit_rounded : Symbols.fullscreen_rounded,
tooltip: isFullscreen ? t.videoControls.exitFullscreenButton : t.videoControls.fullscreenButton,
semanticLabel: isFullscreen ? t.videoControls.exitFullscreenButton : t.videoControls.fullscreenButton,
tracks: tracks,
isMobile: isMobile,
isDesktop: isDesktop,
onPressed: onToggleFullscreen,
),
);
}
return IntrinsicHeight(
child: Row(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: buttons),
);
},
);
}
/// Calculate total button count for navigation
int _getButtonCount(Tracks? tracks, bool isMobile, bool isDesktop) {
int count = 1; // Settings button always shown
if (_hasMultipleAudioTracks(tracks) || _hasSubtitles(tracks)) count++;
if (chapters.isNotEmpty && !hideChaptersAndQueue) count++;
if (showQueueButton && onQueueItemSelected != null && !hideChaptersAndQueue) count++;
if (availableVersions.length > 1 && onSwitchVersion != null) count++;
if (onTogglePIPMode != null) count++;
if (onCycleBoxFitMode != null) count++;
if (isMobile && !PlatformDetector.isTV()) count++; // Rotation lock (not on TV)
if (isDesktop && onToggleAlwaysOnTop != null) count++; // Always on top
if (isDesktop) count++; // Fullscreen
return count;
}
bool _hasMultipleAudioTracks(Tracks? tracks) {
if (tracks == null) return false;
return TrackFilterHelper.hasMultipleTracks<AudioTrack>(tracks.audio);
}
bool _hasSubtitles(Tracks? tracks) {
if (tracks == null) return false;
return TrackFilterHelper.hasTracks<SubtitleTrack>(tracks.subtitle);
}
IconData _getBoxFitIcon(int mode) {
switch (mode) {
case 0:
return Symbols.fit_screen_rounded; // contain (letterbox)
case 1:
return Symbols.aspect_ratio_rounded; // cover (fill screen)
case 2:
return Symbols.settings_overscan_rounded; // fill (stretch)
default:
return Symbols.fit_screen_rounded;
}
}
String _getBoxFitTooltip(int mode) {
switch (mode) {
case 0:
return t.videoControls.letterbox;
case 1:
return t.videoControls.fillScreen;
case 2:
return t.videoControls.stretch;
default:
return t.videoControls.letterbox;
}
}
}