Initial commit
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user