Thick timeline with hatched cache range, chapter diamonds and chapter/time hover boxes; collapses to a thin progress line when hidden. Flat icon row with track count badges, shader preset badge, flat dark menus. Own show/hide state, live switch in window and fullscreen. New buffered signal from demuxer-cache-time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1492 lines
52 KiB
Dart
1492 lines
52 KiB
Dart
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/uosc/buttons.dart';
|
|
import '../controls/uosc/controls.dart';
|
|
import '../controls/uosc/timeline.dart';
|
|
import '../playback/media_marks.dart' show SkipOffer;
|
|
|
|
import '../jellyfin/browse_scope.dart' show BrowseStart;
|
|
import '../jellyfin/session_media.dart';
|
|
import '../session/health.dart';
|
|
import '../session/session_coordinator.dart';
|
|
import '../shaders/shader_menu.dart';
|
|
import '../shaders/shader_presets.dart';
|
|
import '../state/settings.dart';
|
|
import '../state/session_state.dart';
|
|
import '../update/updater.dart';
|
|
import '../widgets/dialogs.dart';
|
|
import '../widgets/member_avatar.dart';
|
|
import '../widgets/shown_frame_probe.dart';
|
|
import '../widgets/text_focus.dart';
|
|
import '../widgets/window_buttons.dart';
|
|
import '../widgets/window_drag_layer.dart';
|
|
import 'home/integrations_page.dart';
|
|
import 'home/media_picker.dart';
|
|
import 'session/jellyfin_widgets.dart';
|
|
import 'session/player_controls.dart';
|
|
import 'session/swap_panel.dart';
|
|
|
|
class SessionScreen extends StatefulWidget {
|
|
const SessionScreen({super.key});
|
|
|
|
@override
|
|
State<SessionScreen> createState() => _SessionScreenState();
|
|
}
|
|
|
|
class _SessionScreenState extends State<SessionScreen> {
|
|
// One key per player generation: a reloaded player needs a fresh
|
|
// `Video` (it never swaps controllers in place).
|
|
var _videoGen = -1;
|
|
var _videoKey = GlobalKey<VideoState>();
|
|
bool _refullscreen = false;
|
|
final _frameProbe = ShownFrameProbe();
|
|
final _sheetOpen = signal(false);
|
|
final _confirmLeave = signal(false);
|
|
final _isFullscreen = signal(false);
|
|
DateTime? _lastKeyAt;
|
|
final _chat = TextEditingController();
|
|
final _chatFocus = FocusNode();
|
|
// "Change video": the shared media picker over the video.
|
|
final _swapOpen = signal(false);
|
|
MediaPickerController? _swapPicker;
|
|
bool _integrationsOpen = false;
|
|
late final void Function() _stopKickWatch;
|
|
late final void Function() _stopSheetWatch;
|
|
late final void Function() _stopPlayerWatch;
|
|
late final void Function() _stopShaderWatch;
|
|
late final FocusNode _noFocus;
|
|
bool _shaderMenuOpen = false;
|
|
// Own toast ids, negative: the coordinator counts up from 1.
|
|
int _toastSeq = 0;
|
|
int _shaderNoticeSeen = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final session = SessionState.instance;
|
|
_noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
|
|
session.coordinator.jellyfin.ensureLoaded();
|
|
session.coordinator.playback.shownFrame = _frameProbe.sample;
|
|
HardwareKeyboard.instance.addHandler(_globalKey);
|
|
_stopKickWatch = effect(() {
|
|
if (session.coordinator.kickReason.value != null) {
|
|
session.route.value = AppRoute.home;
|
|
}
|
|
});
|
|
_stopSheetWatch = effect(() {
|
|
if (!_sheetOpen.value) {
|
|
FocusManager.instance.primaryFocus?.unfocus();
|
|
}
|
|
});
|
|
// Shader messages (preset changes, load failures) become toasts.
|
|
_stopShaderWatch = effect(() {
|
|
final n = session.coordinator.playback.shaderNotice.value;
|
|
if (n == null || n.id == _shaderNoticeSeen) return;
|
|
_shaderNoticeSeen = n.id;
|
|
_toast(n.text);
|
|
});
|
|
// The fullscreen route holds its own `Video` of the old player: leave
|
|
// it before the player goes away, re-enter once the new one is up.
|
|
_stopPlayerWatch = effect(() {
|
|
final alive = session.coordinator.playback.alive.value;
|
|
if (!alive && _isFullscreen.peek()) {
|
|
_refullscreen = true;
|
|
if (mounted) Navigator.of(context, rootNavigator: true).maybePop();
|
|
} else if (alive && _refullscreen) {
|
|
_refullscreen = false;
|
|
// Entering before the new output's first frame leaves the
|
|
// fullscreen copy blank for good (seen in a real run).
|
|
() async {
|
|
try {
|
|
await session.coordinator.playback.video.waitUntilFirstFrameRendered
|
|
.timeout(const Duration(seconds: 10));
|
|
} catch (_) {}
|
|
await Future.delayed(const Duration(milliseconds: 100));
|
|
if (mounted && !_isFullscreen.peek() && session.coordinator.playback.alive.peek()) {
|
|
_videoKey.currentState?.enterFullscreen();
|
|
}
|
|
}();
|
|
}
|
|
});
|
|
}
|
|
|
|
void _reloadVideo() {
|
|
final coord = SessionState.instance.coordinator;
|
|
coord.markInput();
|
|
coord.reloadVideo();
|
|
}
|
|
|
|
void _toast(String text) {
|
|
final coord = SessionState.instance.coordinator;
|
|
coord.toasts.value = [
|
|
...coord.toasts.peek(),
|
|
ToastItem(id: --_toastSeq, text: text, at: DateTime.now().toUtc()),
|
|
];
|
|
}
|
|
|
|
/// 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) {
|
|
final s = AppSettings.instance;
|
|
if (quality != null) s.setShaderQuality(quality);
|
|
s.setShaderPreset(preset);
|
|
final coord = SessionState.instance.coordinator;
|
|
coord.markInput();
|
|
coord.playback.applyShaders(announce: true);
|
|
}
|
|
|
|
void _toggleFullscreen() {
|
|
if (_isFullscreen.peek()) {
|
|
if (mounted) {
|
|
Navigator.of(context, rootNavigator: true).maybePop();
|
|
}
|
|
} else {
|
|
_videoKey.currentState?.enterFullscreen();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
HardwareKeyboard.instance.removeHandler(_globalKey);
|
|
final playback = SessionState.instance.coordinator.playback;
|
|
if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null;
|
|
_stopKickWatch();
|
|
_stopSheetWatch();
|
|
_stopPlayerWatch();
|
|
_stopShaderWatch();
|
|
_chat.dispose();
|
|
_chatFocus.dispose();
|
|
_swapPicker?.dispose();
|
|
_noFocus.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// Opens the swap panel: the browser starts on the series of the
|
|
/// episode playing (season selected, episode highlighted), else where it
|
|
/// was last.
|
|
void _openSwap() {
|
|
final coord = SessionState.instance.coordinator;
|
|
coord.markInput();
|
|
final ref = coord.currentRef.peek();
|
|
final accounts = coord.jellyfin;
|
|
final pick = ref == null ? null : pickForRef(accounts, ref);
|
|
final plan = coord.jellyfinPlan.peek();
|
|
_swapPicker?.dispose();
|
|
_swapPicker = MediaPickerController(
|
|
jellyfin: pick,
|
|
source: pick != null || (accounts.loaded && !accounts.isEmpty)
|
|
? MediaKind.jellyfin
|
|
: MediaKind.link,
|
|
);
|
|
_swapStart = ref != null && plan is OwnPlay && plan.item != null
|
|
? BrowseStart.seriesOf(ref.serverId, plan.item!)
|
|
: null;
|
|
_sheetOpen.value = false;
|
|
_swapOpen.value = true;
|
|
}
|
|
|
|
BrowseStart? _swapStart;
|
|
|
|
void _closeSwap() {
|
|
_swapOpen.value = false;
|
|
FocusManager.instance.primaryFocus?.unfocus();
|
|
}
|
|
|
|
/// Integrations over the session (link an account for a blocked item).
|
|
Future<void> _openIntegrations() async {
|
|
if (_integrationsOpen) return;
|
|
_integrationsOpen = true;
|
|
try {
|
|
await Navigator.of(context, rootNavigator: true).push(MaterialPageRoute<void>(
|
|
builder: (_) => Scaffold(
|
|
appBar: AppBar(title: const Text('Integrations')),
|
|
body: IntegrationsPage(jellyfin: SessionState.instance.coordinator.jellyfin),
|
|
),
|
|
));
|
|
} finally {
|
|
_integrationsOpen = false;
|
|
}
|
|
}
|
|
|
|
bool _globalKey(KeyEvent event) {
|
|
if (event is! KeyDownEvent) return false;
|
|
if (SessionState.instance.route.peek() != AppRoute.session) return false;
|
|
if (_integrationsOpen) return false;
|
|
if (_swapOpen.peek()) {
|
|
// The picker owns the keyboard; Esc outside a field closes it.
|
|
if (event.logicalKey == LogicalKeyboardKey.escape && !textInputFocused()) {
|
|
_closeSwap();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
if (textInputFocused()) return false;
|
|
final now = DateTime.now().toUtc();
|
|
if (_lastKeyAt != null && now.difference(_lastKeyAt!).inMilliseconds < 350) {
|
|
return false;
|
|
}
|
|
final coord = SessionState.instance.coordinator;
|
|
// Ctrl+0..6: Anime4K's own mpv keys (off, modes A..C+A).
|
|
final digit = _shaderKeys.indexOf(event.logicalKey);
|
|
if (digit >= 0 && HardwareKeyboard.instance.isControlPressed) {
|
|
_lastKeyAt = now;
|
|
_pickShader(
|
|
digit == 0 ? ShaderPreset.off : ShaderPreset.modes[digit - 1], null);
|
|
return true;
|
|
}
|
|
if (event.logicalKey == LogicalKeyboardKey.keyR &&
|
|
HardwareKeyboard.instance.isControlPressed) {
|
|
_lastKeyAt = now;
|
|
_reloadVideo();
|
|
return true;
|
|
}
|
|
if (event.logicalKey == LogicalKeyboardKey.space) {
|
|
_lastKeyAt = now;
|
|
coord.toggleReady();
|
|
return true;
|
|
}
|
|
// mpv's playlist keys (< >) and Shift+P / Shift+N: Jellyfin episodes.
|
|
final shift = HardwareKeyboard.instance.isShiftPressed;
|
|
if (event.character == '>' || (shift && event.logicalKey == LogicalKeyboardKey.keyN)) {
|
|
if (coord.nextEpisode.peek() == null) return false;
|
|
_lastKeyAt = now;
|
|
coord.playNextEpisode();
|
|
return true;
|
|
}
|
|
if (event.character == '<' || (shift && event.logicalKey == LogicalKeyboardKey.keyP)) {
|
|
if (coord.previousEpisode.peek() == null) return false;
|
|
_lastKeyAt = now;
|
|
coord.playPreviousEpisode();
|
|
return true;
|
|
}
|
|
// mpv's chapter keys.
|
|
if (event.logicalKey == LogicalKeyboardKey.pageDown ||
|
|
event.logicalKey == LogicalKeyboardKey.pageUp) {
|
|
_lastKeyAt = now;
|
|
return coord.stepChapter(event.logicalKey == LogicalKeyboardKey.pageDown ? 1 : -1);
|
|
}
|
|
if (shift && event.logicalKey == LogicalKeyboardKey.arrowRight) {
|
|
_lastKeyAt = now;
|
|
coord.skipForward();
|
|
return true;
|
|
}
|
|
if (event.logicalKey == LogicalKeyboardKey.tab) {
|
|
_lastKeyAt = now;
|
|
_sheetOpen.value = !_sheetOpen.peek();
|
|
coord.markInput();
|
|
return true;
|
|
}
|
|
if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
|
|
_lastKeyAt = now;
|
|
coord.nudge(10);
|
|
return true;
|
|
}
|
|
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
|
|
_lastKeyAt = now;
|
|
coord.nudge(-10);
|
|
return true;
|
|
}
|
|
if (event.logicalKey == LogicalKeyboardKey.escape) {
|
|
if (_shaderMenuOpen) {
|
|
if (mounted) Navigator.of(context, rootNavigator: true).maybePop();
|
|
return true;
|
|
}
|
|
if (_sheetOpen.peek()) {
|
|
_sheetOpen.value = false;
|
|
return true;
|
|
}
|
|
if (_confirmLeave.peek()) {
|
|
_confirmLeave.value = false;
|
|
return true;
|
|
}
|
|
if (_isFullscreen.peek()) {
|
|
if (mounted) {
|
|
Navigator.of(context, rootNavigator: true).maybePop();
|
|
}
|
|
return true;
|
|
}
|
|
_lastKeyAt = now;
|
|
_confirmLeave.value = true;
|
|
return true;
|
|
}
|
|
if (event.logicalKey == LogicalKeyboardKey.keyF) {
|
|
_lastKeyAt = now;
|
|
if (_isFullscreen.peek()) {
|
|
if (mounted) {
|
|
Navigator.of(context, rootNavigator: true).maybePop();
|
|
}
|
|
} else {
|
|
_videoKey.currentState?.enterFullscreen();
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final coord = SessionState.instance.coordinator;
|
|
final controlsTheme = MaterialDesktopVideoControlsThemeData(
|
|
playAndPauseOnTap: false,
|
|
modifyVolumeOnScroll: false,
|
|
keyboardShortcuts: {},
|
|
visibleOnMount: true,
|
|
hideMouseOnControlsRemoval: true,
|
|
controlsHoverDuration: const Duration(seconds: 3),
|
|
// 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: _barHeight,
|
|
child: Row(
|
|
children: [
|
|
const Spacer(),
|
|
MaterialDesktopCustomButton(
|
|
icon: const Icon(Icons.group),
|
|
onPressed: () {
|
|
_sheetOpen.value = true;
|
|
coord.markInput();
|
|
},
|
|
),
|
|
SignalBuilder(
|
|
builder: (context) => WindowButtons(focusNode: _noFocus, fullscreen: _isFullscreen.value, onToggleFullscreen: _toggleFullscreen),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
bottomButtonBar: [
|
|
Expanded(
|
|
child: Column(
|
|
children: [
|
|
SignalBuilder(
|
|
builder: (context) => PlayerSeekBar(
|
|
player: coord.playback.player,
|
|
chapters: coord.playback.chapters.value,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Row(children: _bottomButtons(coord)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
return FocusTraversalGroup(
|
|
descendantsAreTraversable: false,
|
|
child: Scaffold(
|
|
backgroundColor: Colors.black,
|
|
// Player lifetime = session (and each "Reload video"): no live
|
|
// player ⇒ no `Video`, so no texture outlives its player.
|
|
body: SignalBuilder(
|
|
builder: (context) {
|
|
final playback = coord.playback;
|
|
final gen = playback.generation.value;
|
|
if (!playback.alive.value) {
|
|
return const Center(
|
|
child: Text('player starting…', style: TextStyle(color: Colors.white)),
|
|
);
|
|
}
|
|
if (gen != _videoGen) {
|
|
_videoGen = gen;
|
|
_videoKey = GlobalKey<VideoState>();
|
|
}
|
|
return MaterialDesktopVideoControlsTheme(
|
|
normal: controlsTheme,
|
|
fullscreen: controlsTheme,
|
|
child: Video(
|
|
key: _videoKey,
|
|
controller: playback.video,
|
|
subtitleViewConfiguration: const SubtitleViewConfiguration(visible: false),
|
|
controls: _sessionControls,
|
|
onEnterFullscreen: () async {
|
|
_isFullscreen.value = true;
|
|
await defaultEnterNativeFullscreen();
|
|
},
|
|
onExitFullscreen: () async {
|
|
_isFullscreen.value = false;
|
|
await defaultExitNativeFullscreen();
|
|
},
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
static const _barHeight = 56.0;
|
|
|
|
List<Widget> _bottomButtons(SessionCoordinator 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,
|
|
),
|
|
),
|
|
const MaterialDesktopVolumeButton(),
|
|
_ReadyControlButton(focusNode: _noFocus),
|
|
_MarkAllReadyControlButton(focusNode: _noFocus),
|
|
_ReloadVideoControlButton(focusNode: _noFocus, 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,
|
|
),
|
|
const MaterialDesktopFullscreenButton(),
|
|
];
|
|
}
|
|
|
|
Widget _sessionControls(VideoState state) {
|
|
return Stack(
|
|
children: [
|
|
// Frozen-picture watchdog samples the route around this anchor.
|
|
_frameProbe.anchor(),
|
|
// Settings → "Experimental: mpv-style controls" swaps them live.
|
|
SessionControlsSwitch(
|
|
material: (_) => MaterialDesktopVideoControls(state),
|
|
mpv: (_) => _mpvControls(),
|
|
),
|
|
_healthOverlay(),
|
|
_waitingOverlay(),
|
|
_jellyfinOverlay(),
|
|
_toastsOverlay(),
|
|
_sheetOverlay(),
|
|
_swapOverlay(),
|
|
_leaveOverlay(),
|
|
Positioned.fill(
|
|
child: SignalBuilder(
|
|
builder: (context) {
|
|
final sheetOpen = _sheetOpen.value;
|
|
final confirm = _confirmLeave.value;
|
|
return WindowDragLayer(
|
|
enabled: !confirm && !_swapOpen.value,
|
|
exclusions: [
|
|
(s) => Rect.fromLTWH(0, s.height - 120, s.width, 120),
|
|
if (sheetOpen) (s) => Rect.fromLTWH(s.width - 380, 0, 380, s.height),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
void _openSheet() {
|
|
_sheetOpen.value = true;
|
|
SessionState.instance.coordinator.markInput();
|
|
}
|
|
|
|
/// The experimental uosc-style controls: same actions as the bars above,
|
|
/// all through the coordinator (seeks via `player.seek`, the user path).
|
|
Widget _mpvControls() {
|
|
final coord = SessionState.instance.coordinator;
|
|
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(
|
|
onDoubleTap: _toggleFullscreen,
|
|
top: SignalBuilder(
|
|
builder: (context) =>
|
|
WindowButtons(focusNode: _noFocus, fullscreen: _isFullscreen.value, onToggleFullscreen: _toggleFullscreen),
|
|
),
|
|
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: _openSwap),
|
|
(title: 'People & chat', hint: 'Tab', run: _openSheet),
|
|
(title: 'Reload video', hint: 'Ctrl+R', run: _reloadVideo),
|
|
(title: 'Mark everyone ready', hint: null, run: coord.forcePlayAll),
|
|
(title: 'Fullscreen', hint: 'F', run: _toggleFullscreen),
|
|
(title: 'Leave server', hint: 'Esc', run: () => _confirmLeave.value = true),
|
|
],
|
|
),
|
|
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: _openSwap),
|
|
UoscButton(icon: Icons.group_outlined, tooltip: 'People & chat (Tab)', onPressed: _openSheet),
|
|
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),
|
|
),
|
|
),
|
|
SignalBuilder(
|
|
builder: (context) => UoscButton(
|
|
icon: _isFullscreen.value ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded,
|
|
tooltip: 'Fullscreen (F)',
|
|
onPressed: _toggleFullscreen,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _healthOverlay() {
|
|
final coord = SessionState.instance.coordinator;
|
|
return Positioned(
|
|
top: 12,
|
|
left: 12,
|
|
child: SignalBuilder(
|
|
builder: (context) {
|
|
final h = coord.health.value;
|
|
final starve = coord.starving.value;
|
|
final retrying = coord.reconnecting.value;
|
|
final kicked = coord.kickReason.value;
|
|
final unwell = h.severity == HealthSeverity.warn || h.severity == HealthSeverity.error || retrying || starve != null;
|
|
if (!unwell) return const SizedBox.shrink();
|
|
final color = switch (h.severity) {
|
|
HealthSeverity.ok => Colors.green,
|
|
HealthSeverity.info => Colors.grey,
|
|
HealthSeverity.warn => Colors.orange,
|
|
HealthSeverity.error => Colors.red,
|
|
};
|
|
final IconData icon;
|
|
if (retrying) {
|
|
icon = Icons.sync;
|
|
} else if (h.severity == HealthSeverity.error) {
|
|
icon = Icons.error_outline;
|
|
} else if (h.severity == HealthSeverity.warn) {
|
|
icon = Icons.warning_amber_outlined;
|
|
} else {
|
|
icon = Icons.hourglass_empty;
|
|
}
|
|
final text = [h.message, if (starve != null) starve, if (retrying) 'reconnecting…'].join(' · ');
|
|
final canRetry = !retrying && kicked == null && (h.severity == HealthSeverity.error || h.severity == HealthSeverity.warn);
|
|
return Container(
|
|
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.65), borderRadius: BorderRadius.circular(999)),
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, color: color, size: 16),
|
|
const SizedBox(width: 8),
|
|
Flexible(
|
|
child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 12)),
|
|
),
|
|
if (retrying)
|
|
TextButton(
|
|
style: TextButton.styleFrom(foregroundColor: Colors.white, tapTargetSize: MaterialTapTargetSize.shrinkWrap),
|
|
onPressed: () => coord.cancelReconnect(),
|
|
child: const Text('Cancel', style: TextStyle(fontSize: 12)),
|
|
),
|
|
if (canRetry)
|
|
TextButton(
|
|
style: TextButton.styleFrom(foregroundColor: Colors.white, tapTargetSize: MaterialTapTargetSize.shrinkWrap),
|
|
onPressed: () => coord.retry(),
|
|
child: const Text('Retry', style: TextStyle(fontSize: 12)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _waitingOverlay() {
|
|
final coord = SessionState.instance.coordinator;
|
|
return SignalBuilder(
|
|
builder: (context) {
|
|
final r = coord.serverState.value;
|
|
final waiting = [
|
|
for (final m in r?.members ?? [])
|
|
if (!m.ready) m,
|
|
];
|
|
final show = r != null && r.transport == 'held' && waiting.isNotEmpty;
|
|
final selfReady = coord.selfMember.value?.ready ?? false;
|
|
return Positioned.fill(
|
|
child: Center(
|
|
child: Visibility(
|
|
visible: show,
|
|
maintainState: true,
|
|
maintainAnimation: true,
|
|
maintainSize: true,
|
|
child: Column(
|
|
mainAxisSize: .min,
|
|
spacing: 12,
|
|
children: [
|
|
IgnorePointer(
|
|
child: Container(
|
|
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.7), borderRadius: BorderRadiusGeometry.circular(20)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: Column(
|
|
mainAxisSize: .min,
|
|
spacing: 10,
|
|
crossAxisAlignment: .center,
|
|
children: [
|
|
Text("Waiting for", style: TextTheme.of(context).labelLarge),
|
|
Wrap(
|
|
alignment: WrapAlignment.center,
|
|
spacing: -20,
|
|
runSpacing: 8,
|
|
children: [
|
|
for (final m in waiting)
|
|
Opacity(
|
|
opacity: m.stale ? 0.5 : 1.0,
|
|
child: MemberAvatar(avatarBase64: m.avatar, name: m.name, size: 96),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (!selfReady)
|
|
FilledButton(
|
|
focusNode: _noFocus,
|
|
onPressed: () => coord.toggleReady(),
|
|
child: const Text("I'm Ready"),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Jellyfin: the blocked card, the end-of-episode card (countdown) and
|
|
/// the "Next episode" pill; for any media the skip pill (Skip OP / ED /
|
|
/// 1:30) above it. Outside the auto-hiding controls, so they also show
|
|
/// in fullscreen.
|
|
Widget _jellyfinOverlay() {
|
|
final coord = SessionState.instance.coordinator;
|
|
return Positioned.fill(
|
|
child: SignalBuilder(
|
|
builder: (context) {
|
|
final blocked = coord.jellyfinBlocked.value;
|
|
final next = coord.nextEpisode.value;
|
|
final countdown = coord.autoAdvance.value;
|
|
final finished = coord.serverState.value?.transport == 'finished';
|
|
final pill = coord.nextPromptVisible.value;
|
|
final SkipOffer? skip = coord.skipOffer.value;
|
|
final showNext = next != null && pill;
|
|
return Stack(
|
|
children: [
|
|
if (blocked != null)
|
|
Center(
|
|
child: JellyfinBlockedCard(
|
|
blocked: blocked,
|
|
onLinkAccount: _openIntegrations,
|
|
onRetry: coord.retryJellyfin,
|
|
),
|
|
),
|
|
if (next != null && (finished || countdown != null))
|
|
Positioned(
|
|
right: 24,
|
|
bottom: 138,
|
|
child: EndOfEpisodeCard(
|
|
next: next,
|
|
countdown: countdown,
|
|
onPlayNow: coord.playNextEpisode,
|
|
onCancel: coord.cancelAutoAdvance,
|
|
),
|
|
)
|
|
else if (showNext || skip != null)
|
|
Positioned(
|
|
right: 24,
|
|
bottom: 124,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
spacing: 10,
|
|
children: [
|
|
if (skip != null) SkipPill(offer: skip, onPressed: () => coord.skip(skip)),
|
|
if (showNext) NextEpisodePill(episode: next, onPressed: coord.playNextEpisode),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _swapOverlay() {
|
|
final coord = SessionState.instance.coordinator;
|
|
return SignalBuilder(
|
|
builder: (context) {
|
|
final picker = _swapPicker;
|
|
if (!_swapOpen.value || picker == null) return const SizedBox.shrink();
|
|
return Positioned.fill(
|
|
child: Stack(
|
|
children: [
|
|
Positioned.fill(
|
|
child: GestureDetector(
|
|
onTap: _closeSwap,
|
|
child: Container(color: Colors.black54),
|
|
),
|
|
),
|
|
Positioned.fill(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(32, 48, 32, 32),
|
|
child: Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 1400),
|
|
child: SwapPanel(
|
|
controller: picker,
|
|
accounts: coord.jellyfin,
|
|
isHost: coord.hostFile != null,
|
|
jellyfinStart: _swapStart,
|
|
onSwap: coord.swapPicked,
|
|
onClose: _closeSwap,
|
|
onOpenIntegrations: _openIntegrations,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _toastsOverlay() {
|
|
final coord = SessionState.instance.coordinator;
|
|
return Positioned(
|
|
right: 12,
|
|
top: 56,
|
|
child: IgnorePointer(
|
|
child: SignalBuilder(
|
|
builder: (context) {
|
|
final items = coord.toasts.value;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [for (final t in items.reversed.take(5)) _ToastCard(key: ValueKey(t.id), text: t.text)],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _sheetOverlay() {
|
|
return SignalBuilder(
|
|
builder: (context) {
|
|
final open = _sheetOpen.value;
|
|
return ExcludeFocus(
|
|
excluding: !open,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
Positioned.fill(
|
|
child: IgnorePointer(
|
|
ignoring: !open,
|
|
child: AnimatedOpacity(
|
|
duration: const Duration(milliseconds: 200),
|
|
opacity: open ? 1 : 0,
|
|
child: GestureDetector(
|
|
onTap: () => _sheetOpen.value = false,
|
|
child: Container(color: Colors.black54),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
top: 20,
|
|
bottom: 20,
|
|
right: 0,
|
|
child: IgnorePointer(
|
|
ignoring: !open,
|
|
child: AnimatedSlide(
|
|
duration: const Duration(milliseconds: 250),
|
|
curve: Curves.easeOut,
|
|
offset: open ? Offset.zero : const Offset(1, 0),
|
|
child: SizedBox(
|
|
width: 380,
|
|
height: double.infinity,
|
|
child: _SheetPanel(
|
|
chat: _chat,
|
|
chatFocus: _chatFocus,
|
|
onChangeVideo: _openSwap,
|
|
onClose: () => _sheetOpen.value = false,
|
|
onLeave: () {
|
|
_sheetOpen.value = false;
|
|
_confirmLeave.value = true;
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _leaveOverlay() {
|
|
final session = SessionState.instance;
|
|
return SignalBuilder(
|
|
builder: (context) {
|
|
if (!_confirmLeave.value) return const SizedBox.shrink();
|
|
return Positioned.fill(
|
|
child: GestureDetector(
|
|
onTap: () => _confirmLeave.value = false,
|
|
child: Container(
|
|
color: Colors.black54,
|
|
child: Center(
|
|
child: GestureDetector(
|
|
onTap: () {},
|
|
child: AlertDialog(
|
|
title: const Text('Leave server?'),
|
|
content: const Text('You can rejoin right away.'),
|
|
actions: [
|
|
TextButton(onPressed: () => _confirmLeave.value = false, child: const Text('Cancel')),
|
|
TextButton(
|
|
onPressed: () {
|
|
_confirmLeave.value = false;
|
|
session.leave();
|
|
},
|
|
child: const Text('Leave'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
const _shaderKeys = [
|
|
LogicalKeyboardKey.digit0,
|
|
LogicalKeyboardKey.digit1,
|
|
LogicalKeyboardKey.digit2,
|
|
LogicalKeyboardKey.digit3,
|
|
LogicalKeyboardKey.digit4,
|
|
LogicalKeyboardKey.digit5,
|
|
LogicalKeyboardKey.digit6,
|
|
];
|
|
|
|
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 TextButton(
|
|
focusNode: focusNode,
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
),
|
|
onPressed: () => SessionState.instance.coordinator.toggleReady(),
|
|
child: Text(ready ? 'Ready ✓' : 'Not ready', style: const TextStyle(fontSize: 12)),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MarkAllReadyControlButton extends StatelessWidget {
|
|
final FocusNode focusNode;
|
|
const _MarkAllReadyControlButton({required this.focusNode});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return TextButton(
|
|
focusNode: focusNode,
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
),
|
|
onPressed: () => SessionState.instance.coordinator.forcePlayAll(),
|
|
child: const Text('Mark everyone ready', style: TextStyle(fontSize: 12)),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ReloadVideoControlButton extends StatelessWidget {
|
|
final FocusNode focusNode;
|
|
final VoidCallback onPressed;
|
|
const _ReloadVideoControlButton({required this.focusNode, required this.onPressed});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Tooltip(
|
|
message: 'Reload video (Ctrl+R) — fixes a frozen picture',
|
|
child: TextButton.icon(
|
|
focusNode: focusNode,
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
),
|
|
onPressed: onPressed,
|
|
icon: const Icon(Icons.refresh, size: 16),
|
|
label: const Text('Reload video', style: TextStyle(fontSize: 12)),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ToastCard extends StatefulWidget {
|
|
final String text;
|
|
const _ToastCard({super.key, required this.text});
|
|
|
|
@override
|
|
State<_ToastCard> createState() => _ToastCardState();
|
|
}
|
|
|
|
class _ToastCardState extends State<_ToastCard> {
|
|
bool _shown = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) setState(() => _shown = true);
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedSlide(
|
|
duration: const Duration(milliseconds: 250),
|
|
curve: Curves.easeOut,
|
|
offset: _shown ? Offset.zero : const Offset(1, 0),
|
|
child: AnimatedOpacity(
|
|
duration: const Duration(milliseconds: 250),
|
|
opacity: _shown ? 1 : 0,
|
|
child: Card(
|
|
child: Padding(padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Text(widget.text)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SheetPanel extends StatelessWidget {
|
|
final TextEditingController chat;
|
|
final FocusNode chatFocus;
|
|
final VoidCallback onChangeVideo;
|
|
final VoidCallback onClose;
|
|
final VoidCallback onLeave;
|
|
const _SheetPanel({required this.chat, required this.chatFocus, required this.onChangeVideo, required this.onClose, required this.onLeave});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final coord = SessionState.instance.coordinator;
|
|
return Material(
|
|
borderRadius: BorderRadius.horizontal(left: .circular(20)),
|
|
elevation: 8,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
|
child: SignalBuilder(
|
|
builder: (context) {
|
|
final r = coord.serverState.value;
|
|
final log = coord.feedLog.value;
|
|
final theme = Theme.of(context);
|
|
final sectionStyle = theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold);
|
|
final members = r?.members ?? const <MemberView>[];
|
|
final readyCount = members.where((m) => m.ready).length;
|
|
final playing = r?.transport == 'playing';
|
|
return Column(
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text('Server', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
|
const SizedBox(width: 8),
|
|
_StatusChip(playing: playing, hasState: r != null),
|
|
],
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
r == null ? 'connecting…' : '${members.length} member${members.length == 1 ? '' : 's'} · $readyCount ready',
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
IconButton(icon: const Icon(Icons.close), onPressed: onClose),
|
|
],
|
|
),
|
|
const Divider(height: 20),
|
|
Text('Users (${members.length})', style: sectionStyle),
|
|
const SizedBox(height: 2),
|
|
for (final m in members)
|
|
_MemberRow(
|
|
member: m,
|
|
isSelf: m.id == coord.sync.memberId,
|
|
viaHost: viaHostOf(m, coord.currentRef.value),
|
|
),
|
|
const Divider(height: 20),
|
|
Text('Activity', style: sectionStyle),
|
|
const SizedBox(height: 6),
|
|
if (log.isEmpty) Text('No events yet', style: theme.textTheme.bodySmall),
|
|
Expanded(
|
|
child: ListView.builder(
|
|
reverse: true,
|
|
itemBuilder: (context, index) => _ActivityRow(item: log.reversed.toList()[index]),
|
|
itemCount: log.length,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: chat,
|
|
focusNode: chatFocus,
|
|
decoration: const InputDecoration(labelText: 'Chat message', border: OutlineInputBorder(), isDense: true),
|
|
onSubmitted: (v) {
|
|
coord.sendChat(v);
|
|
chat.clear();
|
|
chatFocus.requestFocus();
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton(
|
|
icon: const Icon(Icons.send),
|
|
onPressed: () {
|
|
coord.sendChat(chat.text);
|
|
chat.clear();
|
|
chatFocus.requestFocus();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: OutlinedButton.icon(
|
|
key: const ValueKey('change-video'),
|
|
icon: const Icon(Icons.swap_horiz),
|
|
label: const Text('Change video'),
|
|
onPressed: onChangeVideo,
|
|
),
|
|
),
|
|
const Divider(height: 20),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: OutlinedButton.icon(
|
|
style: OutlinedButton.styleFrom(foregroundColor: theme.colorScheme.error),
|
|
icon: const Icon(Icons.logout),
|
|
label: const Text('Leave server'),
|
|
onPressed: onLeave,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
const Center(
|
|
child: Text('v$appVersion', style: TextStyle(fontSize: 11, color: Colors.grey)),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatusChip extends StatelessWidget {
|
|
final bool playing;
|
|
final bool hasState;
|
|
const _StatusChip({required this.playing, required this.hasState});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final Color dot;
|
|
final String label;
|
|
if (!hasState) {
|
|
dot = Colors.grey;
|
|
label = '…';
|
|
} else if (playing) {
|
|
dot = Colors.green;
|
|
label = 'Playing';
|
|
} else {
|
|
dot = Colors.orange;
|
|
label = 'Waiting';
|
|
}
|
|
return Container(
|
|
decoration: BoxDecoration(color: Colors.white10, borderRadius: BorderRadius.circular(999)),
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 7,
|
|
height: 7,
|
|
decoration: BoxDecoration(shape: BoxShape.circle, color: dot),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(label, style: Theme.of(context).textTheme.labelSmall),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The member plays the current Jellyfin item through the host's relay.
|
|
bool viaHostOf(MemberView m, JellyfinRef? ref) => ref != null && m.jf[ref.serverId] == false;
|
|
|
|
class _MemberRow extends StatelessWidget {
|
|
final MemberView member;
|
|
final bool isSelf;
|
|
final bool viaHost;
|
|
const _MemberRow({required this.member, required this.isSelf, this.viaHost = false});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final coord = SessionState.instance.coordinator;
|
|
final theme = Theme.of(context);
|
|
final status = _memberStatus(member);
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
child: Row(
|
|
children: [
|
|
MemberAvatar(avatarBase64: member.avatar, name: member.name, size: 32),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
isSelf ? '${member.name} (you)' : member.name,
|
|
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 2),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: BoxDecoration(shape: BoxShape.circle, color: status.$2),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
status.$1,
|
|
style: theme.textTheme.bodySmall?.copyWith(color: theme.textTheme.bodySmall?.color?.withValues(alpha: 0.75)),
|
|
),
|
|
if (viaHost) ...[const SizedBox(width: 6), const ViaHostBadge()],
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
PopupMenuButton<String>(
|
|
icon: const Icon(Icons.more_vert, size: 18),
|
|
padding: EdgeInsets.zero,
|
|
onSelected: (v) async {
|
|
if (v == 'force') {
|
|
coord.forceTarget(member.id, !member.ready);
|
|
} else if (v == 'kick') {
|
|
final ok = await showKickConfirmDialog(context, member.name);
|
|
if (ok == true) coord.kick(member.id);
|
|
}
|
|
},
|
|
itemBuilder: (_) => [
|
|
PopupMenuItem(value: 'force', child: Text(member.ready ? 'Force unready' : 'Force ready')),
|
|
PopupMenuItem(
|
|
value: 'kick',
|
|
child: Text('Kick', style: TextStyle(color: theme.colorScheme.error)),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
(String, Color) _memberStatus(MemberView m) {
|
|
if (m.stale) return ('Stale', Colors.orange);
|
|
if (!m.ready) return ('Not ready', Colors.grey);
|
|
if (m.buffered < RelayConstants.minBufferSecs) return ('Buffering ${m.buffered.toStringAsFixed(0)}s', Colors.orange);
|
|
return ('Ready', Colors.green);
|
|
}
|
|
|
|
class _ActivityRow extends StatelessWidget {
|
|
final FeedMessage item;
|
|
const _ActivityRow({required this.item});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final time = _clock(item.at);
|
|
final subtle = theme.textTheme.bodySmall?.color?.withValues(alpha: 0.6) ?? Colors.grey;
|
|
if (item.action == 'chat') {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Icon(Icons.chat_bubble_outline, size: 15, color: Colors.grey),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text.rich(
|
|
TextSpan(
|
|
children: [
|
|
TextSpan(
|
|
text: '${item.actor} ',
|
|
style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
TextSpan(text: item.reason, style: theme.textTheme.bodySmall),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(time, style: theme.textTheme.labelSmall?.copyWith(color: subtle)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
if (item.actor == 'server') {
|
|
final style = _serverFeedStyle(item);
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(style.$1, size: 15, color: style.$2),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: Text(style.$3, style: theme.textTheme.bodySmall)),
|
|
const SizedBox(width: 8),
|
|
Text(time, style: theme.textTheme.labelSmall?.copyWith(color: subtle)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
final style = _feedStyle(item, theme);
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(style.$1, size: 15, color: style.$2),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text.rich(
|
|
TextSpan(
|
|
children: [
|
|
TextSpan(
|
|
text: item.actor,
|
|
style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
TextSpan(text: ' ${style.$3}', style: theme.textTheme.bodySmall),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(time, style: theme.textTheme.labelSmall?.copyWith(color: subtle)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
(IconData, Color, String) _feedStyle(FeedMessage f, ThemeData theme) {
|
|
switch (f.action) {
|
|
case 'join':
|
|
return (Icons.person_add, Colors.green, 'joined');
|
|
case 'leave':
|
|
final reason = f.reason == 'silent too long' ? ' · timed out' : (f.reason.isEmpty ? '' : ' · ${f.reason}');
|
|
return (Icons.logout, Colors.grey, 'left$reason');
|
|
case 'ready':
|
|
return (Icons.check_circle, Colors.green, 'is ready');
|
|
case 'unready':
|
|
return (Icons.radio_button_unchecked, Colors.grey, "isn't ready");
|
|
case 'force-ready':
|
|
return (Icons.how_to_reg, Colors.orange, 'marked ${f.target ?? ''} ready');
|
|
case 'force-unready':
|
|
return (Icons.how_to_reg, Colors.orange, 'marked ${f.target ?? ''} not ready');
|
|
case 'force-play':
|
|
return (Icons.play_arrow, Colors.green, 'started playback for everyone');
|
|
case 'force-hold':
|
|
return (Icons.pause, Colors.orange, 'held playback for everyone');
|
|
case 'seek':
|
|
return (Icons.fast_forward, theme.colorScheme.primary, 'seeked');
|
|
case 'swap':
|
|
return (Icons.movie, theme.colorScheme.primary, 'changed the media');
|
|
case 'stale':
|
|
return (Icons.cloud_off, Colors.orange, 'lost signal');
|
|
case 'kick':
|
|
final reason = f.reason.isEmpty ? '' : ' · ${f.reason}';
|
|
return (Icons.gavel, Colors.red, 'kicked ${f.target ?? ''}$reason');
|
|
default:
|
|
final target = f.target == null || f.target!.isEmpty ? '' : ' ${f.target}';
|
|
return (Icons.info_outline, Colors.grey, '${f.action}$target');
|
|
}
|
|
}
|
|
|
|
(IconData, Color, String) _serverFeedStyle(FeedMessage f) {
|
|
switch (f.action) {
|
|
case 'resume':
|
|
return (Icons.play_arrow, Colors.green, 'Playback resumed');
|
|
default:
|
|
return (Icons.pause, Colors.orange, 'Playback paused · waiting for readiness');
|
|
}
|
|
}
|
|
|
|
String _clock(DateTime at) {
|
|
final l = at.toLocal();
|
|
String two(int n) => n.toString().padLeft(2, '0');
|
|
return '${two(l.hour)}:${two(l.minute)}';
|
|
}
|