Swap shows only the new media, no guessed endings, quieter screen
- A swap stops the previous media at once; buffer counts only for the media the player holds (client) and the current mediaId (server), so the old video can no longer start the new one. - The automatic frozen-picture reload is gone (false alarms on still scenes); Reload video stays. - The "Next episode" pill shows only from a known ending. - A web link's thumbnail is its cover, under the video's own name. - While playing, the unread chip stays off the picture; chat pop-ups can be All / Only when paused / Off. - Server fetch failures say when the server has no cookies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ab133dd82f
commit
27b4ec2c96
25 files changed
+274
-485
No files matched your search
@@ -47,18 +47,20 @@ Widget fadeInImage(ImageProvider image, {BoxFit fit = BoxFit.cover}) => Image(
|
||||
);
|
||||
|
||||
/// The portrait cover (2:3) of the current media, a quiet tile with a
|
||||
/// film icon until (or unless) its art arrives.
|
||||
/// film icon until (or unless) its art arrives; [wide] (a video
|
||||
/// thumbnail): 16:9 at the same height.
|
||||
class ArtCover extends StatelessWidget {
|
||||
final String hash;
|
||||
final double width;
|
||||
const ArtCover({super.key, required this.hash, this.width = 40});
|
||||
final bool wide;
|
||||
const ArtCover({super.key, required this.hash, this.width = 40, this.wide = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final image = artImage(hash);
|
||||
final r = BorderRadius.circular(width >= 80 ? 10 : 6);
|
||||
return Container(
|
||||
width: width,
|
||||
width: wide ? width * 1.5 * 16 / 9 : width,
|
||||
height: width * 1.5,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: r,
|
||||
@@ -116,7 +118,7 @@ class NowPlayingBlock extends StatelessWidget {
|
||||
key: ValueKey('now-playing:${n.title}\n${episode ?? ''}'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (cover) ...[ArtCover(hash: n.posterHash), const SizedBox(width: 12)],
|
||||
if (cover) ...[ArtCover(hash: n.posterHash, wide: n.thumbnail), const SizedBox(width: 12)],
|
||||
Flexible(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -23,7 +23,6 @@ import '../shaders/shader_presets.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../state/settings.dart';
|
||||
import '../widgets/app_keys.dart';
|
||||
import '../widgets/shown_frame_probe.dart';
|
||||
import '../widgets/text_focus.dart';
|
||||
import '../widgets/window_buttons.dart';
|
||||
import 'avatar.dart';
|
||||
@@ -93,7 +92,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
// `Video` (it never swaps controllers in place).
|
||||
var _videoGen = -1;
|
||||
var _videoKey = GlobalKey<VideoState>();
|
||||
final _frameProbe = ShownFrameProbe();
|
||||
final _noFocus = FocusNode(skipTraversal: true, canRequestFocus: false);
|
||||
DateTime? _lastKeyAt;
|
||||
late final void Function() _stopShaderWatch;
|
||||
@@ -114,7 +112,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_coord.jellyfin.ensureLoaded();
|
||||
_coord.playback.shownFrame = _frameProbe.sample;
|
||||
HardwareKeyboard.instance.addHandler(_globalKey);
|
||||
// Tab (AppKeys) reaches the chat through here in fullscreen.
|
||||
AppKeys.addChatToggle(_toggleChat);
|
||||
@@ -132,8 +129,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
void dispose() {
|
||||
HardwareKeyboard.instance.removeHandler(_globalKey);
|
||||
AppKeys.removeChatToggle(_toggleChat);
|
||||
final playback = _coord.playback;
|
||||
if (playback.shownFrame == _frameProbe.sample) playback.shownFrame = null;
|
||||
_stopShaderWatch();
|
||||
_noFocus.dispose();
|
||||
_controlsShown.dispose();
|
||||
@@ -287,11 +282,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// The frozen-picture watchdog captures this boundary: the
|
||||
// video (and its controls), not the window around it.
|
||||
RepaintBoundary(
|
||||
child: Stack(fit: StackFit.expand, children: [video, _frameProbe.anchor()]),
|
||||
),
|
||||
video,
|
||||
if (_full) ...[
|
||||
_status(),
|
||||
_progressLine(),
|
||||
@@ -755,15 +746,18 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
);
|
||||
|
||||
/// Fullscreen hides the strip and its chat badge: while there is unread
|
||||
/// chat and the controls are down, a small glass chip says so top right.
|
||||
/// chat, the controls are down and the video is not playing, a small
|
||||
/// glass chip says so top right (while it plays, only the controls' chat
|
||||
/// button shows the count: nothing sits on the picture).
|
||||
Widget _unreadChip() => Positioned(
|
||||
right: 16,
|
||||
top: 16,
|
||||
child: IgnorePointer(
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: _controlsShown,
|
||||
builder: (context, shown, _) {
|
||||
final on = widget.unread > 0 && !widget.chatOpen && !shown;
|
||||
builder: (context, shown, _) => SignalBuilder(builder: (context) {
|
||||
final playing = _coord.serverState.value?.transport == 'playing';
|
||||
final on = widget.unread > 0 && !widget.chatOpen && !shown && !playing;
|
||||
return AnimatedOpacity(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
@@ -783,7 +777,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -723,8 +723,8 @@ class _AnimeFlowCardState extends State<AnimeFlowCard> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What pops up and what makes a sound. No switches: there is nothing to
|
||||
/// choose yet.
|
||||
/// What pops up (chat pop-ups: [AppSettings.chatPopups]) and what makes
|
||||
/// a sound.
|
||||
class NotificationsInfo extends StatelessWidget {
|
||||
const NotificationsInfo({super.key});
|
||||
|
||||
@@ -746,11 +746,39 @@ class NotificationsInfo extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
line('Joins, leaves, video changes and chat',
|
||||
line('Joins, leaves and video changes',
|
||||
'Pop up bottom left for 5 s, with the person\'s picture'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Chat messages'),
|
||||
Text('Pop up the same way; always in Chat & activity (Tab)', style: muted),
|
||||
],
|
||||
),
|
||||
),
|
||||
SignalBuilder(
|
||||
builder: (context) => DropdownButton<ChatPopups>(
|
||||
key: const ValueKey('chat-popups'),
|
||||
value: AppSettings.instance.chatPopups.value,
|
||||
onChanged: (v) {
|
||||
if (v != null) AppSettings.instance.setChatPopups(v);
|
||||
},
|
||||
items: [
|
||||
for (final m in ChatPopups.values) DropdownMenuItem(value: m, child: Text(m.label)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
line('Sat out or nudged by someone', 'Only on your end, with their picture'),
|
||||
line('Ready, seeks and pauses', 'Only in Chat & activity (Tab), never as a pop-up'),
|
||||
line('App notices', 'Shader changes, a frozen video reloaded, the idle warning'),
|
||||
line('App notices', 'Shader changes, the idle warning'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -82,7 +82,8 @@ class StartCard extends StatelessWidget {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (!small) ...[
|
||||
// A thumbnail already fills the card as the banner.
|
||||
if (!small && !now.thumbnail) ...[
|
||||
ArtCover(hash: now.posterHash, width: coverWidth),
|
||||
const SizedBox(width: 24),
|
||||
],
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:signals/signals_flutter.dart';
|
||||
|
||||
import '../session/session_coordinator.dart' show ToastItem;
|
||||
import '../state/session_state.dart';
|
||||
import '../state/settings.dart';
|
||||
import 'avatar.dart';
|
||||
import 'motion.dart';
|
||||
|
||||
@@ -47,7 +48,7 @@ ShellToast shellToastFor(ToastItem t, AvatarInfo Function(String name) avatar) {
|
||||
/// The coordinator's toasts as cards with avatars, bottom-left: each
|
||||
/// slides in, stays [life], then slides out and collapses its gap. A click
|
||||
/// on a chat toast opens the chat ([onOpenChat]); the rest let clicks
|
||||
/// through.
|
||||
/// through. Chat toasts follow [AppSettings.chatPopups].
|
||||
class PartyToasts extends StatefulWidget {
|
||||
final Duration life;
|
||||
final VoidCallback? onOpenChat;
|
||||
@@ -76,8 +77,13 @@ class _PartyToastsState extends State<PartyToasts> {
|
||||
if (fresh.isEmpty) return;
|
||||
// Only new toasts re-run this, not the party they mention.
|
||||
untracked(() {
|
||||
final playing = coord.serverState.value?.transport == 'playing';
|
||||
final popups = AppSettings.instance.chatPopups.value;
|
||||
for (final t in fresh) {
|
||||
_add(shellToastFor(t, (n) => avatarForName(n, coord)));
|
||||
final toast = shellToastFor(t, (n) => avatarForName(n, coord));
|
||||
// Chat per Settings → Notifications (it is in the chat either way).
|
||||
if (toast.chat && !popups.shows(playing: playing)) continue;
|
||||
_add(toast);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user