Files

466 lines
14 KiB
Dart
Raw Permalink Normal View History

2026-07-14 11:11:36 +08:00
part of 'player_page.dart';
extension _PlayerPageBottomBar on _PlayerPageState {
Widget _buildBottomBar(PlaybackSession session) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!_pip.isPip) _buildLogoRow(session),
DebouncedSeekBar(
player: session.engine,
onDragChanged: (t) => _seekPreview.value = t,
),
SizedBox(
height: _pip.isPip ? 32 : 52,
child: ValueListenableBuilder<int>(
valueListenable: _barVersion,
builder: (context, _, _) {
if (_pip.isPip) return _buildPipBottomRow(session);
return _buildFullBottomRow(session);
},
),
),
],
);
}
Widget _buildLogoRow(PlaybackSession session) {
final logoUrl = session.context.logoUrl;
if (logoUrl == null) return const SizedBox.shrink();
final imageHeaders = ref.watch(embyImageHeadersProvider).value;
return Padding(
padding: const EdgeInsets.only(left: 16, bottom: 8),
child: Align(
alignment: Alignment.centerLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200, maxHeight: 48),
child: CachedNetworkImage(
imageUrl: logoUrl,
httpHeaders: imageHeaders,
fit: BoxFit.contain,
alignment: Alignment.bottomLeft,
memCacheWidth: 600,
fadeInDuration: const Duration(milliseconds: 200),
placeholder: (_, _) => const SizedBox.shrink(),
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
),
),
);
}
List<Widget> _transportControls(
PlaybackSession session, {
double? iconSize,
bool compact = false,
}) {
final pl = _playlist;
final hasPrev = pl?.hasPrev ?? false;
final hasNext = pl?.hasNext ?? false;
final btnConstraints = compact
? const BoxConstraints(minWidth: 36, minHeight: 36)
: null;
final btnPadding = compact ? EdgeInsets.zero : null;
return [
IconButton(
onPressed: hasPrev ? () => unawaited(pl!.prev()) : null,
icon: Icon(
Icons.skip_previous,
color: hasPrev ? Colors.white : Colors.white38,
size: iconSize,
),
tooltip: '上一集',
constraints: btnConstraints,
padding: btnPadding,
),
PlayerPlayPauseButton(
player: session.engine,
iconSize: compact ? 24.0 : null,
constraints: btnConstraints,
),
IconButton(
onPressed: hasNext ? () => unawaited(pl!.next()) : null,
icon: Icon(
Icons.skip_next,
color: hasNext ? Colors.white : Colors.white38,
size: iconSize,
),
tooltip: '下一集',
constraints: btnConstraints,
padding: btnPadding,
),
];
}
Widget _buildPipBottomRow(PlaybackSession session) {
return Row(
children: [
const Spacer(),
PlayerPositionIndicator(
player: session.engine,
seekPreview: _seekPreview,
),
const SizedBox(width: 4),
IconButton(
onPressed: () => unawaited(_togglePip()),
icon: const Icon(Icons.open_in_full, color: Colors.white, size: 20),
tooltip: '退出画中画 (P)',
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
const SizedBox(width: 8),
],
);
}
Widget _buildPipCenterControls(PlaybackSession session) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {},
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(40),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: _transportControls(session, iconSize: 26, compact: true),
),
),
),
);
}
Widget _buildFullBottomRow(PlaybackSession session) {
final allMediaSources = session.context.allMediaSources;
final embySubtitles = session.context.embySubtitles;
final hasPicker = session.context.seriesId != null;
final compactRow = Row(
children: [
..._transportControls(session, iconSize: 24, compact: true),
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: PlayerPositionIndicator(
player: session.engine,
seekPreview: _seekPreview,
),
),
),
_compactMoreButton(session),
IconButton(
tooltip: _fullscreen.isFullscreen ? '退出全屏' : '全屏',
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
padding: EdgeInsets.zero,
onPressed: () => unawaited(_toggleFullscreen()),
icon: Icon(
_fullscreen.isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
color: Colors.white,
size: 28,
),
),
],
);
final row = Row(
children: [
..._transportControls(session),
if (!Platform.isAndroid)
PlayerVolumeButton(
player: session.engine,
volumeBoost: ref.watch(
audioSettingsProvider.select(
(s) => s.value?.volumeBoost ?? false,
),
),
),
PlayerPositionIndicator(
player: session.engine,
seekPreview: _seekPreview,
),
const Spacer(),
if (allMediaSources.length > 1 || _crossServerCards.isNotEmpty)
ValueListenableBuilder<bool>(
valueListenable: _versionBarOpen,
builder: (context, open, _) => PanelToggleAffordance(
isOpen: open,
onTap: _toggleVersionBar,
tooltip: '切换版本',
child: const Icon(
Icons.switch_video,
color: Colors.white,
size: 26,
),
),
),
CompositedTransformTarget(
link: _panelLinks[PlayerPanelCategory.subtitle]!,
child: SubtitleTracksButton(
player: session.engine,
embySubtitles: embySubtitles,
isPanelOpen: _drawer.category == PlayerPanelCategory.subtitle,
onTogglePanel: () => _togglePanel(PlayerPanelCategory.subtitle),
),
),
CompositedTransformTarget(
link: _panelLinks[PlayerPanelCategory.audio]!,
child: AudioTracksButton(
player: session.engine,
isPanelOpen: _drawer.category == PlayerPanelCategory.audio,
onTogglePanel: () => _togglePanel(PlayerPanelCategory.audio),
),
),
CompositedTransformTarget(
link: _panelLinks[PlayerPanelCategory.speed]!,
child: SpeedButton(
currentRate: _playbackRate,
isPanelOpen: _drawer.category == PlayerPanelCategory.speed,
onTogglePanel: () => _togglePanel(PlayerPanelCategory.speed),
),
),
CompositedTransformTarget(
link: _panelLinks[PlayerPanelCategory.fit]!,
child: FitButton(
currentFit: _videoFit.value,
isPanelOpen: _drawer.category == PlayerPanelCategory.fit,
onTogglePanel: () => _togglePanel(PlayerPanelCategory.fit),
),
),
if (hasPicker) _panelIcon(PlayerPanelCategory.episode),
_panelIcon(PlayerPanelCategory.danmaku),
if (_isDesktopWindow)
IconButton(
onPressed: () => unawaited(_togglePip()),
icon: const Icon(
Icons.picture_in_picture,
color: Colors.white,
size: 24,
),
tooltip: '画中画 (P)',
),
IconButton(
tooltip: _fullscreen.isFullscreen ? '退出全屏' : '全屏',
onPressed: () => unawaited(_toggleFullscreen()),
icon: Icon(
_fullscreen.isFullscreen
? Icons.fullscreen_exit
: Icons.fullscreen,
color: Colors.white,
size: 28,
),
),
],
);
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < Breakpoints.compact) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: compactRow,
);
}
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: IntrinsicWidth(child: row),
),
);
},
);
}
Widget _compactMoreButton(PlaybackSession session) {
final hasVersion =
session.context.allMediaSources.length > 1 ||
_crossServerCards.isNotEmpty;
final hasPicker = session.context.seriesId != null;
final items = <_CompactMoreItem>[
if (hasVersion)
_CompactMoreItem(
icon: Icons.switch_video,
label: '切换版本',
onTap: _toggleVersionBar,
),
_CompactMoreItem.panel(
PlayerPanelCategory.subtitle,
(c) => _showCompactPanelSheet(session, c),
),
_CompactMoreItem.panel(
PlayerPanelCategory.audio,
(c) => _showCompactPanelSheet(session, c),
),
_CompactMoreItem.panel(
PlayerPanelCategory.speed,
(c) => _showCompactPanelSheet(session, c),
),
_CompactMoreItem.panel(
PlayerPanelCategory.fit,
(c) => _showCompactPanelSheet(session, c),
),
if (hasPicker)
_CompactMoreItem.panel(
PlayerPanelCategory.episode,
(c) => _showCompactPanelSheet(session, c),
),
_CompactMoreItem.panel(
PlayerPanelCategory.danmaku,
(c) => _showCompactPanelSheet(session, c),
),
];
return IconButton(
tooltip: '更多',
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
padding: EdgeInsets.zero,
icon: const Icon(Icons.more_horiz, color: Colors.white, size: 26),
onPressed: () => _showCompactMoreSheet(items),
);
}
Future<void> _showCompactPanelSheet(
PlaybackSession session,
PlayerPanelCategory category,
) async {
_versionBarOpen.value = false;
_drawer.close();
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) {
if (category == PlayerPanelCategory.episode) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _episodeBodyKey.currentState?.ensureLoaded(),
);
}
return SafeArea(
top: false,
child: Container(
margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.72,
),
child: PlayerPanelShell(
icon: panelCategoryIcon(category),
title: panelCategoryLabel(category),
width: double.infinity,
onClose: () => Navigator.of(context).pop(),
child: _panelBody(session, category),
),
),
);
},
);
}
Future<void> _showCompactMoreSheet(List<_CompactMoreItem> items) async {
await showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.transparent,
builder: (sheetContext) => SafeArea(
top: false,
child: Container(
margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
decoration: BoxDecoration(
color: kPanelBg,
borderRadius: BorderRadius.circular(18),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
child: Row(
children: [
const Text(
'更多控制',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
onPressed: () => Navigator.of(sheetContext).pop(),
icon: const Icon(
Icons.close,
color: Colors.white70,
size: 20,
),
),
],
),
),
panelDivider(),
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.56,
),
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: items.length,
separatorBuilder: (_, _) =>
panelDivider(),
itemBuilder: (context, i) {
final item = items[i];
return ListTile(
leading: Icon(item.icon, color: Colors.white70),
title: Text(
item.label,
style: const TextStyle(color: Colors.white),
),
trailing: const Icon(
Icons.chevron_right_rounded,
color: Colors.white38,
),
onTap: () {
Navigator.of(sheetContext).pop();
item.onTap();
},
);
},
),
),
],
),
),
),
);
}
}
class _CompactMoreItem {
final IconData icon;
final String label;
final VoidCallback onTap;
const _CompactMoreItem({
required this.icon,
required this.label,
required this.onTap,
});
factory _CompactMoreItem.panel(
PlayerPanelCategory category,
ValueChanged<PlayerPanelCategory> onToggle,
) {
return _CompactMoreItem(
icon: panelCategoryIcon(category),
label: panelCategoryLabel(category),
onTap: () => onToggle(category),
);
}
}