466 lines
14 KiB
Dart
466 lines
14 KiB
Dart
|
|
|
|
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),
|
|
);
|
|
}
|
|
}
|