forked from Foxyz/sync_replay_project
Player controls: one model, two skins (Standard, Minimal); conventional layout; controls settings
PlayerControlsModel holds the controls' state and actions once; the Standard skin (our own, no more media_kit controls or private-state reach-ins) and Minimal (the former uosc design, made the app's own and credited) only draw it. One layout spec for both: play first, then previous/next, volume, time; subtitles, audio, settings, chat, fullscreen last; everything else in the settings menu. Settings -> Player -> Controls: time display, size, hide delay, optional buttons. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
959eed38a4
commit
fd8064611a
40 files changed
+3820
-3975
No files matched your search
@@ -7,6 +7,7 @@ import 'package:fox_utils/fox_utils.dart' show ClipboardX, DesktopDropX, DropTar
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../controls/frame.dart' show DoubleClickListener;
|
||||
import '../files/folder_store.dart';
|
||||
import '../files/ui/files_page.dart' show showFilesFolder;
|
||||
import '../jellyfin/accounts.dart';
|
||||
|
||||
@@ -2,26 +2,20 @@ import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||
import 'package:flutter/gestures.dart' show kPrimaryMouseButton;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../controls/controls_probe.dart';
|
||||
import '../controls/player_settings.dart';
|
||||
import '../controls/uosc/buttons.dart';
|
||||
import '../controls/uosc/controls.dart';
|
||||
import '../controls/uosc/timeline.dart';
|
||||
import '../controls/uosc/volume_flash.dart';
|
||||
import '../controls/model/controls_model.dart';
|
||||
import '../controls/session_controls.dart';
|
||||
import '../controls/volume.dart';
|
||||
import '../queue/queue_ui.dart';
|
||||
import '../playback/media_marks.dart' show SkipKind, SkipOffer, formatClock;
|
||||
import '../screens/session/jellyfin_widgets.dart';
|
||||
import '../screens/session/player_controls.dart';
|
||||
import '../session/session_coordinator.dart';
|
||||
import '../shaders/shader_menu.dart';
|
||||
import '../shaders/shader_presets.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../state/settings.dart';
|
||||
@@ -29,7 +23,6 @@ import '../widgets/app_keys.dart';
|
||||
import '../widgets/text_focus.dart';
|
||||
import '../widgets/window_buttons.dart';
|
||||
import 'avatar.dart';
|
||||
import 'mark_problem.dart';
|
||||
import 'motion.dart';
|
||||
import 'now_playing_title.dart';
|
||||
import 'player_status.dart';
|
||||
@@ -46,8 +39,8 @@ enum PlayerMode { none, mini, full }
|
||||
typedef VideoViewBuilder = Widget Function(BuildContext context, VideoControlsBuilder? controls);
|
||||
|
||||
/// The session's player: the one `Video` of the current player and
|
||||
/// everything over it. Full: our bars on media_kit's controls or the uosc
|
||||
/// set (Settings), the start card, the status pill, Jellyfin overlays and
|
||||
/// everything over it. Full: the controls (one model, the skin picked in
|
||||
/// Settings), the start card, the status pill, Jellyfin overlays and
|
||||
/// the player keys. Mini: hover controls and a progress line. The shell keeps this
|
||||
/// widget mounted at one place in the tree while the mode changes, so the
|
||||
/// `Video` is never mounted twice nor rebuilt for a move.
|
||||
@@ -99,21 +92,31 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
|
||||
DateTime? _lastKeyAt;
|
||||
late final void Function() _stopShaderWatch;
|
||||
bool _shaderMenuOpen = false;
|
||||
// Own toast ids, negative: the coordinator counts up from 1.
|
||||
int _toastSeq = 0;
|
||||
int _shaderNoticeSeen = 0;
|
||||
bool _miniHover = false;
|
||||
// Whether the controls are up: the standard ones tracked like media_kit
|
||||
// does (shown on mount and on mouse movement, hidden 3 s later or on
|
||||
// exit), the uosc ones reporting it.
|
||||
// Whether the controls are up (their frame reports it).
|
||||
final _controlsShown = ValueNotifier(true);
|
||||
// The mouse on a floating element, or the Up next popover open, keeps
|
||||
// the controls up.
|
||||
final _hold = ControlsHold();
|
||||
// Ticks on each wheel / key volume step: flashes the volume readout.
|
||||
final _volumeFlash = ValueNotifier(0);
|
||||
late final void Function() _stopPopoverWatch;
|
||||
// The shell's state the controls show, as signals for the model.
|
||||
late final _fullscreen = signal(widget.fullscreen);
|
||||
late final _chatOpen = signal(widget.chatOpen);
|
||||
late final _unread = signal(widget.unread);
|
||||
late final PlayerControlsModel _model = PlayerControlsModel(
|
||||
coord: _coord,
|
||||
fullscreen: _fullscreen,
|
||||
chatOpen: _chatOpen,
|
||||
unread: _unread,
|
||||
onToggleFullscreen: () => widget.onToggleFullscreen(),
|
||||
onToggleChat: () => widget.onToggleChat(),
|
||||
onChangeVideo: () => widget.onChangeVideo(),
|
||||
onCollapse: () => widget.onCollapse(),
|
||||
onLeave: () => widget.onLeave(),
|
||||
);
|
||||
|
||||
SessionCoordinator get _coord => SessionState.instance.coordinator;
|
||||
bool get _full => widget.mode == PlayerMode.full;
|
||||
@@ -138,6 +141,14 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SessionPlayer oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_fullscreen.value = widget.fullscreen;
|
||||
_chatOpen.value = widget.chatOpen;
|
||||
_unread.value = widget.unread;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
HardwareKeyboard.instance.removeHandler(_globalKey);
|
||||
@@ -147,15 +158,10 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
_noFocus.dispose();
|
||||
_controlsShown.dispose();
|
||||
_hold.dispose();
|
||||
_volumeFlash.dispose();
|
||||
_model.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _reloadVideo() {
|
||||
_coord.markInput();
|
||||
_coord.reloadVideo();
|
||||
}
|
||||
|
||||
void _toast(String text) {
|
||||
_coord.toasts.value = [
|
||||
..._coord.toasts.peek(),
|
||||
@@ -163,22 +169,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
];
|
||||
}
|
||||
|
||||
/// Menu, Ctrl+0..6: saved as the choice (also used on the next join)
|
||||
/// and applied live. A null quality keeps the saved one.
|
||||
void _pickShader(ShaderPreset preset, ShaderQuality? quality) => applyShaderChoice(preset, quality);
|
||||
|
||||
/// One volume step up (+1) or down (-1), from the wheel or ↑ / ↓;
|
||||
/// flashes the design's volume readout.
|
||||
void _stepVolume(int step) {
|
||||
final player = _coord.playback.player;
|
||||
player.setVolume(stepVolume(player.state.volume, step * volumeStep));
|
||||
_volumeFlash.value++;
|
||||
}
|
||||
|
||||
void _toggleChat() {
|
||||
_coord.markInput();
|
||||
widget.onToggleChat();
|
||||
}
|
||||
void _toggleChat() => _model.toggleChat();
|
||||
|
||||
/// The player keys, while the player fills the body and nothing (a
|
||||
/// dialog, a card, a text field) is in front of it. Space and Tab are
|
||||
@@ -196,7 +187,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
final route = ModalRoute.of(context);
|
||||
if (route != null && !route.isCurrent) {
|
||||
// A menu of ours is open: Esc closes it.
|
||||
if (_shaderMenuOpen && event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
if (_model.menusOpen > 0 && event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
Navigator.of(context, rootNavigator: true).maybePop();
|
||||
return true;
|
||||
}
|
||||
@@ -207,62 +198,55 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) {
|
||||
return false;
|
||||
}
|
||||
final coord = _coord;
|
||||
final model = _model;
|
||||
final key = event.logicalKey;
|
||||
// Ctrl+0..6: Anime4K's own mpv keys (off, modes A..C+A).
|
||||
final digit = _shaderKeys.indexOf(key);
|
||||
if (digit >= 0 && HardwareKeyboard.instance.isControlPressed) {
|
||||
_lastKeyAt = now;
|
||||
_pickShader(digit == 0 ? ShaderPreset.off : ShaderPreset.modes[digit - 1], null);
|
||||
model.pickShader(digit == 0 ? ShaderPreset.off : ShaderPreset.modes[digit - 1], null);
|
||||
return true;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.keyR && HardwareKeyboard.instance.isControlPressed) {
|
||||
_lastKeyAt = now;
|
||||
_reloadVideo();
|
||||
model.reload();
|
||||
return true;
|
||||
}
|
||||
// mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes.
|
||||
final shift = HardwareKeyboard.instance.isShiftPressed;
|
||||
if (event.character == '>' || (shift && key == LogicalKeyboardKey.keyN)) {
|
||||
// Up next first, then the next episode.
|
||||
if (coord.queue.items.peek().isNotEmpty) {
|
||||
_lastKeyAt = now;
|
||||
coord.queue.skip();
|
||||
return true;
|
||||
}
|
||||
if (coord.nextEpisode.peek() == null) return false;
|
||||
if (!model.next()) return false;
|
||||
_lastKeyAt = now;
|
||||
coord.playNextEpisode();
|
||||
return true;
|
||||
}
|
||||
if (event.character == '<' || (shift && key == LogicalKeyboardKey.keyP)) {
|
||||
if (coord.previousEpisode.peek() == null) return false;
|
||||
if (!model.previous()) return false;
|
||||
_lastKeyAt = now;
|
||||
coord.playPreviousEpisode();
|
||||
return true;
|
||||
}
|
||||
// mpv's chapter keys.
|
||||
if (key == LogicalKeyboardKey.pageDown || key == LogicalKeyboardKey.pageUp) {
|
||||
_lastKeyAt = now;
|
||||
return coord.stepChapter(key == LogicalKeyboardKey.pageDown ? 1 : -1);
|
||||
return model.stepChapter(key == LogicalKeyboardKey.pageDown ? 1 : -1);
|
||||
}
|
||||
if (shift && key == LogicalKeyboardKey.arrowRight) {
|
||||
_lastKeyAt = now;
|
||||
coord.skipForward();
|
||||
model.skipForward();
|
||||
return true;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.arrowDown) {
|
||||
_stepVolume(key == LogicalKeyboardKey.arrowUp ? 1 : -1);
|
||||
model.stepVolume(key == LogicalKeyboardKey.arrowUp ? 1 : -1);
|
||||
return true;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.arrowRight) {
|
||||
_lastKeyAt = now;
|
||||
coord.nudge(10);
|
||||
model.nudge(10);
|
||||
return true;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.arrowLeft) {
|
||||
_lastKeyAt = now;
|
||||
coord.nudge(-10);
|
||||
model.nudge(-10);
|
||||
return true;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.escape) {
|
||||
@@ -312,13 +296,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
// (lists, menus and popovers under the mouse scroll instead).
|
||||
return VolumeWheel(
|
||||
enabled: _full,
|
||||
onStep: _stepVolume,
|
||||
onStep: _model.stepVolume,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
video,
|
||||
if (_full) ...[
|
||||
_progressLine(),
|
||||
_blockedCard(),
|
||||
_actionArea(),
|
||||
if (widget.fullscreen) ...[if (!SessionState.instance.solo.value) _pausedParty(), _unreadChip()],
|
||||
@@ -336,24 +319,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _video(VideoControlsBuilder? controls) {
|
||||
final theme = _controlsTheme();
|
||||
return MaterialDesktopVideoControlsTheme(
|
||||
normal: theme,
|
||||
fullscreen: theme,
|
||||
child: Video(
|
||||
Widget _video(VideoControlsBuilder? controls) => Video(
|
||||
key: _videoKey,
|
||||
controller: _coord.playback.video,
|
||||
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
|
||||
controls: controls,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static const _barHeight = 56.0;
|
||||
/// The standard top bar: room for the cover (2:3, 60 px tall).
|
||||
static const _topBarHeight = 72.0;
|
||||
static const _controlsHide = Duration(seconds: 3);
|
||||
);
|
||||
|
||||
/// Fullscreen hides the strip and its window buttons: the top bar
|
||||
/// carries them while the controls are up (restore leaves fullscreen).
|
||||
@@ -373,337 +344,18 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
);
|
||||
}
|
||||
|
||||
/// The standard design's progress line while its controls are hidden
|
||||
/// (the uosc timeline collapses into its own).
|
||||
Widget _progressLine() => Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: IgnorePointer(
|
||||
child: SignalBuilder(builder: (context) {
|
||||
final s = AppSettings.instance;
|
||||
if (s.mpvControls.value || !s.progressLine.value) return const SizedBox.shrink();
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: _controlsShown,
|
||||
builder: (context, shown, _) => shown
|
||||
? const SizedBox.shrink()
|
||||
: const _MiniSeekBar(key: ValueKey('progress-line-standard'), thin: true),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
MaterialDesktopVideoControlsThemeData _controlsTheme() {
|
||||
final coord = _coord;
|
||||
return MaterialDesktopVideoControlsThemeData(
|
||||
playAndPauseOnTap: false,
|
||||
modifyVolumeOnScroll: false,
|
||||
// media_kit's double-click pushes its own fullscreen route (a second
|
||||
// `Video`); ours toggles the window instead (see [DoubleClickListener]).
|
||||
toggleFullscreenOnDoublePress: false,
|
||||
keyboardShortcuts: {},
|
||||
visibleOnMount: true,
|
||||
hideMouseOnControlsRemoval: true,
|
||||
controlsHoverDuration: _controlsHide,
|
||||
// Our seek bar (chapter marks, hover title) sits in the bottom bar,
|
||||
// which grows by its height; the top bar keeps its row at the top.
|
||||
displaySeekBar: false,
|
||||
buttonBarHeight: _barHeight + ChapterSeekBar.height,
|
||||
topButtonBar: [
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: SizedBox(
|
||||
height: _topBarHeight,
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(child: NowPlayingTitle()),
|
||||
UpNextButton(
|
||||
queue: coord.queue,
|
||||
onPlayNextEpisode: coord.playNextEpisode,
|
||||
look: UpNextLook.standard,
|
||||
hold: _hold),
|
||||
MaterialDesktopCustomButton(
|
||||
icon: const Icon(Icons.video_library_outlined),
|
||||
onPressed: widget.onChangeVideo,
|
||||
),
|
||||
MaterialDesktopCustomButton(
|
||||
icon: Badge(
|
||||
isLabelVisible: widget.unread > 0,
|
||||
label: Text('${widget.unread}'),
|
||||
child: Icon(widget.chatOpen ? Icons.chat_bubble : Icons.chat_bubble_outline),
|
||||
),
|
||||
onPressed: _toggleChat,
|
||||
),
|
||||
MaterialDesktopCustomButton(
|
||||
icon: Icon(widget.fullscreen ? Icons.fullscreen_exit : Icons.close_fullscreen),
|
||||
onPressed: widget.fullscreen ? widget.onToggleFullscreen : widget.onCollapse,
|
||||
),
|
||||
?_fullscreenWindowButtons(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
bottomButtonBar: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
SignalBuilder(
|
||||
builder: (context) => PlayerSeekBar(
|
||||
player: coord.playback.player,
|
||||
chapters: coord.playback.chapters.value,
|
||||
),
|
||||
),
|
||||
Expanded(child: Row(children: _bottomButtons())),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _bottomButtons() {
|
||||
final coord = _coord;
|
||||
final playback = coord.playback;
|
||||
void menuOpen(bool open) => _shaderMenuOpen = open;
|
||||
void pickTrack(TrackPick p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff);
|
||||
return [
|
||||
SignalBuilder(
|
||||
builder: (context) => EpisodeSkipButton(
|
||||
focusNode: _noFocus,
|
||||
episode: coord.previousEpisode.value,
|
||||
next: false,
|
||||
onPressed: coord.playPreviousEpisode,
|
||||
),
|
||||
),
|
||||
SignalBuilder(
|
||||
builder: (context) => EpisodeSkipButton(
|
||||
focusNode: _noFocus,
|
||||
episode: coord.nextEpisode.value,
|
||||
next: true,
|
||||
onPressed: coord.playNextEpisode,
|
||||
),
|
||||
),
|
||||
VolumeButton(player: playback.player, focusNode: _noFocus),
|
||||
_ReadyControlButton(focusNode: _noFocus),
|
||||
_BarTextButton(
|
||||
focusNode: _noFocus,
|
||||
label: 'Mark everyone ready',
|
||||
onPressed: coord.forcePlayAll,
|
||||
),
|
||||
_BarTextButton(
|
||||
focusNode: _noFocus,
|
||||
icon: Icons.refresh,
|
||||
label: 'Reload video',
|
||||
tooltip: 'Reload video (Ctrl+R) — fixes a frozen picture',
|
||||
onPressed: _reloadVideo,
|
||||
),
|
||||
const MaterialDesktopPositionIndicator(),
|
||||
const Spacer(),
|
||||
SkipForwardButton(focusNode: _noFocus, onPressed: coord.skipForward),
|
||||
SignalBuilder(
|
||||
builder: (context) => ChaptersControlButton(
|
||||
focusNode: _noFocus,
|
||||
chapters: playback.chapters.value,
|
||||
current: playback.currentChapter.value,
|
||||
onPick: (c) => coord.seekTo(c.start.inMicroseconds / 1e6),
|
||||
onMenuOpen: menuOpen,
|
||||
),
|
||||
),
|
||||
SignalBuilder(
|
||||
builder: (context) => TrackMenuButton(
|
||||
focusNode: _noFocus,
|
||||
type: JellyfinStreamType.audio,
|
||||
tracks: playback.tracks.value,
|
||||
onPick: pickTrack,
|
||||
onMenuOpen: menuOpen,
|
||||
),
|
||||
),
|
||||
SignalBuilder(
|
||||
builder: (context) => TrackMenuButton(
|
||||
focusNode: _noFocus,
|
||||
type: JellyfinStreamType.subtitle,
|
||||
tracks: playback.tracks.value,
|
||||
onPick: pickTrack,
|
||||
onMenuOpen: menuOpen,
|
||||
),
|
||||
),
|
||||
ShaderControlButton(
|
||||
focusNode: _noFocus,
|
||||
renderMode: playback.renderMode,
|
||||
onPick: _pickShader,
|
||||
onMenuOpen: menuOpen,
|
||||
),
|
||||
PlayerSettingsButton(focusNode: _noFocus, renderMode: playback.renderMode),
|
||||
MaterialDesktopCustomButton(
|
||||
icon: Icon(widget.fullscreen ? Icons.fullscreen_exit : Icons.fullscreen),
|
||||
onPressed: widget.onToggleFullscreen,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _sessionControls(VideoState state) {
|
||||
// The start card sits under the controls (they stay usable on it).
|
||||
// The "Player design" setting swaps them live.
|
||||
return Stack(fit: StackFit.expand, children: [const StartBanner(), _controlsSwitch(state)]);
|
||||
}
|
||||
|
||||
Widget _controlsSwitch(VideoState state) {
|
||||
return SessionControlsSwitch(
|
||||
material: (_) => ControlsHoverProbe(
|
||||
shown: _controlsShown,
|
||||
hideAfter: _controlsHide,
|
||||
hold: _hold,
|
||||
child: DoubleClickListener(
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
const StartBanner(),
|
||||
SessionControls(
|
||||
model: _model,
|
||||
shown: _controlsShown,
|
||||
hold: _hold,
|
||||
onDoubleClick: widget.onToggleFullscreen,
|
||||
// Clicks on the bars are button clicks, not the video.
|
||||
skipTop: _topBarHeight,
|
||||
skipBottom: _barHeight + ChapterSeekBar.height,
|
||||
child: MaterialDesktopVideoControls(state),
|
||||
),
|
||||
),
|
||||
mpv: (_) => _mpvControls(),
|
||||
);
|
||||
}
|
||||
|
||||
/// The uosc controls: same actions as the bars above,
|
||||
/// all through the coordinator (seeks via `player.seek`, the user path).
|
||||
Widget _mpvControls() {
|
||||
final coord = _coord;
|
||||
final playback = coord.playback;
|
||||
void menuOpen(bool open) => _shaderMenuOpen = open;
|
||||
void pickTrack(TrackPick p) => coord.selectTrack(aid: p.aid, sid: p.sid, subtitlesOff: p.subtitlesOff);
|
||||
Widget episode(bool next) => SignalBuilder(builder: (context) {
|
||||
final e = next ? coord.nextEpisode.value : coord.previousEpisode.value;
|
||||
if (e == null) return const SizedBox.shrink();
|
||||
return UoscButton(
|
||||
key: ValueKey(next ? 'episode-next' : 'episode-previous'),
|
||||
icon: next ? Icons.skip_next_rounded : Icons.skip_previous_rounded,
|
||||
tooltip: '${next ? 'Next' : 'Previous'}: ${e.label} (${next ? '> or Shift+N' : '< or Shift+P'})',
|
||||
onPressed: next ? coord.playNextEpisode : coord.playPreviousEpisode,
|
||||
);
|
||||
});
|
||||
return UoscControls(
|
||||
shown: _controlsShown,
|
||||
holdBy: _hold,
|
||||
onDoubleTap: widget.onToggleFullscreen,
|
||||
topLeft: const NowPlayingTitle(),
|
||||
// Up next is one of the top bar's controls, before chat.
|
||||
top: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
UpNextButton(
|
||||
queue: coord.queue, onPlayNextEpisode: coord.playNextEpisode, look: UpNextLook.uosc, hold: _hold),
|
||||
UoscButton(
|
||||
key: const ValueKey('uosc-chat'),
|
||||
icon: widget.chatOpen ? Icons.chat_bubble_rounded : Icons.chat_bubble_outline_rounded,
|
||||
tooltip: 'Chat & activity (Tab)',
|
||||
badge: widget.unread,
|
||||
onPressed: _toggleChat,
|
||||
),
|
||||
UoscButton(
|
||||
icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.close_fullscreen_rounded,
|
||||
tooltip: widget.fullscreen ? 'Exit fullscreen (Esc)' : 'Back to the library (keeps playing)',
|
||||
onPressed: widget.fullscreen ? widget.onToggleFullscreen : widget.onCollapse,
|
||||
),
|
||||
?_fullscreenWindowButtons(),
|
||||
],
|
||||
),
|
||||
timeline: (context, expanded) => SignalBuilder(
|
||||
builder: (context) => UoscTimeline(
|
||||
position: playback.position.value,
|
||||
duration: playback.duration.value,
|
||||
buffered: playback.buffered.value,
|
||||
chapters: playback.chapters.value,
|
||||
expanded: expanded,
|
||||
onSeek: (d) => playback.player.seek(d),
|
||||
),
|
||||
),
|
||||
left: [
|
||||
UoscMainMenuButton(
|
||||
onMenuOpen: menuOpen,
|
||||
actions: [
|
||||
(title: 'Change video', hint: null, run: widget.onChangeVideo),
|
||||
(title: 'Chat & activity', hint: 'Tab', run: _toggleChat),
|
||||
(title: 'Reload video', hint: 'Ctrl+R', run: _reloadVideo),
|
||||
(title: 'Mark a problem', hint: 'Ctrl+Shift+B', run: () => showMarkProblem(context)),
|
||||
(title: 'Mark everyone ready', hint: null, run: coord.forcePlayAll),
|
||||
(title: 'Fullscreen', hint: 'F', run: widget.onToggleFullscreen),
|
||||
(title: 'Leave server', hint: 'Esc', run: widget.onLeave),
|
||||
],
|
||||
),
|
||||
SignalBuilder(
|
||||
builder: (context) => UoscTracksButton(
|
||||
type: JellyfinStreamType.subtitle,
|
||||
tracks: playback.tracks.value,
|
||||
onPick: pickTrack,
|
||||
onMenuOpen: menuOpen,
|
||||
),
|
||||
),
|
||||
SignalBuilder(
|
||||
builder: (context) => UoscTracksButton(
|
||||
type: JellyfinStreamType.audio,
|
||||
tracks: playback.tracks.value,
|
||||
onPick: pickTrack,
|
||||
onMenuOpen: menuOpen,
|
||||
),
|
||||
),
|
||||
UoscShaderButton(renderMode: playback.renderMode, onPick: _pickShader, onMenuOpen: menuOpen),
|
||||
const SizedBox(width: 12),
|
||||
SignalBuilder(builder: (context) {
|
||||
final ready = coord.selfMember.value?.ready ?? false;
|
||||
return UoscButton(
|
||||
key: const ValueKey('uosc-ready'),
|
||||
icon: ready ? Icons.check_circle_rounded : Icons.radio_button_unchecked_rounded,
|
||||
label: ready ? 'Ready' : 'Not ready',
|
||||
color: ready ? Colors.greenAccent : null,
|
||||
tooltip: 'Ready / not ready (Space)',
|
||||
onPressed: coord.toggleReady,
|
||||
);
|
||||
}),
|
||||
UoscButton(icon: Icons.done_all_rounded, tooltip: 'Mark everyone ready', onPressed: coord.forcePlayAll),
|
||||
UoscButton(
|
||||
icon: Icons.refresh_rounded,
|
||||
tooltip: 'Reload video (Ctrl+R) — fixes a frozen picture',
|
||||
onPressed: _reloadVideo,
|
||||
),
|
||||
],
|
||||
right: [
|
||||
UoscButton(
|
||||
key: const ValueKey('uosc-skip-forward'),
|
||||
icon: Icons.fast_forward_rounded,
|
||||
label: '1:30',
|
||||
tooltip: 'Skip 1:30 for everyone (Shift+Right)',
|
||||
onPressed: coord.skipForward,
|
||||
),
|
||||
SignalBuilder(
|
||||
builder: (context) => UoscChaptersButton(
|
||||
chapters: playback.chapters.value,
|
||||
current: playback.currentChapter.value,
|
||||
duration: playback.duration.value,
|
||||
onPick: (c) => coord.seekTo(c.start.inMicroseconds / 1e6),
|
||||
onMenuOpen: menuOpen,
|
||||
),
|
||||
),
|
||||
episode(false),
|
||||
episode(true),
|
||||
const SizedBox(width: 12),
|
||||
UoscButton(icon: Icons.video_library_outlined, tooltip: 'Change video', onPressed: widget.onChangeVideo),
|
||||
StreamBuilder<double>(
|
||||
stream: playback.player.stream.volume,
|
||||
initialData: playback.player.state.volume,
|
||||
builder: (context, v) => UoscVolumeButton(
|
||||
volume: v.data ?? 100,
|
||||
onChanged: (x) => playback.player.setVolume(x),
|
||||
),
|
||||
),
|
||||
UoscPlayerSettingsButton(renderMode: playback.renderMode),
|
||||
UoscButton(
|
||||
icon: widget.fullscreen ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded,
|
||||
tooltip: 'Fullscreen (F)',
|
||||
onPressed: widget.onToggleFullscreen,
|
||||
windowButtons: _fullscreenWindowButtons(),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -713,64 +365,26 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
/// the gap between them and the controls.
|
||||
static const _edge = 16.0;
|
||||
|
||||
/// The bottom controls' height in the design in use.
|
||||
static double _bottomControlsHeight(bool uosc) => uosc
|
||||
? UoscControls.buttonRowHeight + UoscControls.rowBottomPadding + UoscTimeline.height
|
||||
: _barHeight + ChapterSeekBar.height;
|
||||
/// The bottom controls' height in the skin and size in use.
|
||||
static double _bottomControlsHeight() {
|
||||
final s = AppSettings.instance;
|
||||
return controlsBottomHeight(s.controlsSkin.value, s.controlsSize.value);
|
||||
}
|
||||
|
||||
/// The volume flash after a wheel or key step, in the design in use:
|
||||
/// uosc's vertical slider at the right edge (centred between the top
|
||||
/// buttons and the bottom controls, as uosc places it), else a pill
|
||||
/// under the top bar.
|
||||
Widget _volumeReadout() {
|
||||
final playback = _coord.playback;
|
||||
Widget withVolume(Widget Function(double v) build) => StreamBuilder<double>(
|
||||
stream: playback.player.stream.volume,
|
||||
initialData: playback.player.state.volume,
|
||||
builder: (context, v) => build(v.data ?? 100),
|
||||
);
|
||||
return SignalBuilder(builder: (context) {
|
||||
if (AppSettings.instance.mpvControls.value) {
|
||||
const top = 12 + UoscControls.buttonRowHeight;
|
||||
final bottom = _bottomControlsHeight(true);
|
||||
return Positioned(
|
||||
right: UoscVolumeSlider.width / 2,
|
||||
top: top,
|
||||
bottom: bottom,
|
||||
width: UoscVolumeSlider.width,
|
||||
child: LayoutBuilder(builder: (context, c) {
|
||||
final h = UoscVolumeSlider.heightFor(c.maxHeight);
|
||||
if (h <= UoscVolumeSlider.width) return const SizedBox.shrink();
|
||||
return Align(
|
||||
// Centred with the mute button's room below, like uosc.
|
||||
alignment: Alignment.center,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: UoscVolumeSlider.width),
|
||||
child: SizedBox(
|
||||
height: h,
|
||||
child: VolumeFlash(
|
||||
trigger: _volumeFlash,
|
||||
builder: (context) => withVolume((v) => UoscVolumeSlider(volume: v)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Positioned(
|
||||
static double get _topBarHeight => controlsTopHeight(AppSettings.instance.controlsSkin.peek());
|
||||
|
||||
/// The volume flash after a wheel or key step: a pill under the top bar.
|
||||
Widget _volumeReadout() => Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: _topBarHeight + 8,
|
||||
child: Center(
|
||||
child: VolumeFlash(
|
||||
trigger: _volumeFlash,
|
||||
builder: (context) => withVolume((v) => VolumeReadout(volume: v)),
|
||||
trigger: _model.volumeFlash,
|
||||
builder: (context) => SignalBuilder(builder: (context) => VolumeReadout(volume: _model.volume.value)),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// The action area: one band above the bottom controls for everything
|
||||
/// that floats there. The status pill (the ready prompt among it)
|
||||
@@ -781,7 +395,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
/// ([ControlsHold]).
|
||||
Widget _actionArea() {
|
||||
return SignalBuilder(builder: (context) {
|
||||
final controls = _bottomControlsHeight(AppSettings.instance.mpvControls.value);
|
||||
final controls = _bottomControlsHeight();
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: _controlsShown,
|
||||
builder: (context, shown, child) => AnimatedPositioned(
|
||||
@@ -848,7 +462,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
announce: skip.kind != SkipKind.ending,
|
||||
controlsShown: _controlsShown,
|
||||
hold: _hold,
|
||||
child: SkipPill(offer: skip, onPressed: () => coord.skip(skip)),
|
||||
child: SkipPill(offer: skip, onPressed: () => _model.skip(skip)),
|
||||
),
|
||||
if (showNext)
|
||||
PillGate(
|
||||
@@ -1171,7 +785,7 @@ const _shaderKeys = [
|
||||
class _MiniSeekBar extends StatelessWidget {
|
||||
final bool thin;
|
||||
final ValueChanged<Duration>? onSeek;
|
||||
const _MiniSeekBar({super.key, this.thin = false, this.onSeek});
|
||||
const _MiniSeekBar({this.thin = false, this.onSeek});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -1199,91 +813,3 @@ class _MiniSeekBar extends StatelessWidget {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Double-click on the picture (not on the bars) without joining the
|
||||
/// gesture arena, so single clicks on buttons stay instant.
|
||||
class DoubleClickListener extends StatefulWidget {
|
||||
final VoidCallback onDoubleClick;
|
||||
final double skipTop, skipBottom;
|
||||
final Widget child;
|
||||
const DoubleClickListener(
|
||||
{super.key, required this.onDoubleClick, this.skipTop = 0, this.skipBottom = 0, required this.child});
|
||||
|
||||
@override
|
||||
State<DoubleClickListener> createState() => _DoubleClickListenerState();
|
||||
}
|
||||
|
||||
class _DoubleClickListenerState extends State<DoubleClickListener> {
|
||||
DateTime? _last;
|
||||
Offset? _lastAt;
|
||||
|
||||
void _down(PointerDownEvent e) {
|
||||
if (e.buttons != kPrimaryMouseButton) return;
|
||||
final h = context.size?.height ?? 0;
|
||||
final y = e.localPosition.dy;
|
||||
if (y < widget.skipTop || y > h - widget.skipBottom) {
|
||||
_last = null;
|
||||
return;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final last = _last, at = _lastAt;
|
||||
if (last != null &&
|
||||
at != null &&
|
||||
now.difference(last) < const Duration(milliseconds: 400) &&
|
||||
(e.localPosition - at).distance < 24) {
|
||||
_last = null;
|
||||
widget.onDoubleClick();
|
||||
return;
|
||||
}
|
||||
_last = now;
|
||||
_lastAt = e.localPosition;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
Listener(behavior: HitTestBehavior.translucent, onPointerDown: _down, child: widget.child);
|
||||
}
|
||||
|
||||
class _ReadyControlButton extends StatelessWidget {
|
||||
final FocusNode focusNode;
|
||||
const _ReadyControlButton({required this.focusNode});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SignalBuilder(
|
||||
builder: (context) {
|
||||
final ready = SessionState.instance.coordinator.selfMember.value?.ready ?? false;
|
||||
return _BarTextButton(
|
||||
focusNode: focusNode,
|
||||
label: ready ? 'Ready ✓' : 'Not ready',
|
||||
onPressed: SessionState.instance.coordinator.toggleReady,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BarTextButton extends StatelessWidget {
|
||||
final FocusNode focusNode;
|
||||
final String label;
|
||||
final IconData? icon;
|
||||
final String? tooltip;
|
||||
final VoidCallback onPressed;
|
||||
const _BarTextButton(
|
||||
{required this.focusNode, required this.label, required this.onPressed, this.icon, this.tooltip});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = TextButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
);
|
||||
final text = Text(label, style: const TextStyle(fontSize: 12));
|
||||
final button = icon == null
|
||||
? TextButton(focusNode: focusNode, style: style, onPressed: onPressed, child: text)
|
||||
: TextButton.icon(
|
||||
focusNode: focusNode, style: style, onPressed: onPressed, icon: Icon(icon, size: 16), label: text);
|
||||
return tooltip == null ? button : Tooltip(message: tooltip, child: button);
|
||||
}
|
||||
}
|
||||
@@ -121,12 +121,16 @@ class SettingsTab extends StatelessWidget {
|
||||
return [
|
||||
const SettingsHintCard(
|
||||
icon: Icons.tune,
|
||||
text: "Player settings are also in the player's ⚙, at the right end of its controls.",
|
||||
text: "Player settings are also in the player: ⚙ → Player settings, at the right of its controls.",
|
||||
),
|
||||
SettingsPanel(
|
||||
title: 'Player',
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: playerSettingRows()),
|
||||
),
|
||||
SettingsPanel(
|
||||
title: 'Controls',
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: controlsSettingRows()),
|
||||
),
|
||||
const SettingsPanel(title: 'Custom shaders', child: CustomShaderFiles()),
|
||||
const SettingsPanel(title: 'yt-dlp', child: YtDlpSettings()),
|
||||
];
|
||||
|
||||
Reference in new issue
Block a user