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:
foxyzandClaude Opus 5.5 committed 2026-10-11 04:31:14 +03:00
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,
+8 -14
View File
@@ -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> {
),
),
);
},
}),
),
),
);
+32 -4
View File
@@ -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'),
],
),
),
+2 -1
View File
@@ -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),
],
+8 -2
View File
@@ -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);
}
});
});