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( 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 _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( 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 _showCompactPanelSheet( PlaybackSession session, PlayerPanelCategory category, ) async { _versionBarOpen.value = false; _drawer.close(); await showModalBottomSheet( 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 _showCompactMoreSheet(List<_CompactMoreItem> items) async { await showModalBottomSheet( 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 onToggle, ) { return _CompactMoreItem( icon: panelCategoryIcon(category), label: panelCategoryLabel(category), onTap: () => onToggle(category), ); } }