Pills only with the controls, web links in their final layout at once, chat pop-ups when paused by default

- Next episode (known ending, else the last two minutes) and Skip ED
  show only while the controls are up, like Skip 1:30 (PillGate).
- A web link's name and wide cover slot show from the first state, so
  nothing flips when its thumbnail arrives.
- Chat pop-ups default to Only when paused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-11 05:13:50 +03:00
1 parent 27b4ec2c96
commit 2a7b553597
14 files changed
+149 -78

No files matched your search

@@ -9,11 +9,11 @@ class NowPlaying {
/// Portrait cover (2:3) and wide banner; '' when none.
final String posterHash;
final String bannerHash;
const NowPlaying({required this.title, this.episode, this.posterHash = '', this.bannerHash = ''});
/// The art is a web video's thumbnail (the same picture as cover and
/// banner): shown wide, under the video's own name.
bool get thumbnail => posterHash.isNotEmpty && posterHash == bannerHash;
/// A web video: its art is its thumbnail (the same picture as cover and
/// banner, or none yet), shown wide, under the video's own name.
final bool thumbnail;
const NowPlaying(
{required this.title, this.episode, this.posterHash = '', this.bannerHash = '', this.thumbnail = false});
@override
bool operator ==(Object other) =>
@@ -21,8 +21,9 @@ class NowPlaying {
other.title == title &&
other.episode == episode &&
other.posterHash == posterHash &&
other.bannerHash == bannerHash;
other.bannerHash == bannerHash &&
other.thumbnail == thumbnail;
@override
int get hashCode => Object.hash(title, episode, posterHash, bannerHash);
int get hashCode => Object.hash(title, episode, posterHash, bannerHash, thumbnail);
}
@@ -1,3 +1,5 @@
import 'package:sync_relay_core/core.dart' show hasMediaExtension;
import '../files/name_parser.dart';
import '../jellyfin/session_media.dart';
import 'now_playing.dart';
@@ -16,17 +18,38 @@ NowPlaying? nowPlayingOf(Map<String, Object?>? media) {
final art = media['art'] is Map ? media['art'] as Map : const {};
final poster = art['poster'] is String ? art['poster'] as String : '';
final banner = art['banner'] is String ? art['banner'] as String : '';
// A web video's thumbnail: its name as is, no episode guessing.
if (poster.isNotEmpty && poster == banner) {
return NowPlaying(title: name, posterHash: poster, bannerHash: banner);
}
final ref = refOfStateMedia(media);
// A web video: its name as is, no episode guessing, its thumbnail as
// the art. Told by the art once it is up (the same picture as cover and
// banner), before that by the media itself, so it never changes layout.
final web = poster.isNotEmpty || banner.isNotEmpty ? poster == banner : ref == null && isWebVideo(media);
if (web) {
return NowPlaying(title: name, posterHash: poster, bannerHash: banner, thumbnail: true);
}
final (title, episode) = ref != null
? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle)
: fileTitle(name);
return NowPlaying(title: title, episode: episode, posterHash: poster, bannerHash: banner);
}
/// [media] (`state.media`, not Jellyfin) is a web page's video, whose art
/// will be its thumbnail: a shared http(s) link that is no media file (the
/// everyone-opens-it mode), or a relayed one (passed on by a member or
/// fetched by the server: the URL stays private) named by the page's title,
/// not a file name. A relayed file keeps its file name's extension.
bool isWebVideo(Map<String, Object?> media) {
final url = media['url'];
if (url is String && url.isNotEmpty) {
final uri = Uri.tryParse(url);
return uri != null &&
(uri.isScheme('http') || uri.isScheme('https')) &&
uri.host.isNotEmpty &&
!hasMediaExtension(uri);
}
final name = media['name'] is String ? media['name'] as String : '';
return media['relay'] is Map && name.isNotEmpty && !_ext.hasMatch(name);
}
/// Jellyfin: [name] is the item's, [subtitle] "Series · S1E6" (or
/// "Series - S01E02") for episodes, the year for movies. Episodes give the
/// series and "S1 E6 · Name"; anything else the name alone.
@@ -200,8 +200,9 @@ class SessionCoordinator {
/// Neighbouring episodes this viewer can put up (own account only).
final previousEpisode = signal<EpisodeLink?>(null);
final nextEpisode = signal<EpisodeLink?>(null);
/// The floating "Next episode" pill from a known ending (Outro segment
/// or ED chapter, [nextPromptStart]) on.
/// The "Next episode" pill's window ([nextPromptStart]: the known ending,
/// else the last two minutes); the pill shows in it only while the
/// controls are up.
final nextPromptVisible = signal(false);
/// End-of-episode countdown; null when none runs here.
final autoAdvance = signal<AutoAdvance?>(null);
@@ -1690,7 +1691,7 @@ class SessionCoordinator {
final item = (jellyfinPlan.peek() as OwnPlay?)?.item;
final at = nextPromptStart(item, Duration(milliseconds: (duration * 1000).round()),
endingStart: playback.ending.peek()?.start);
show = at != null && snap.position * 1000 >= at.inMilliseconds;
show = snap.position * 1000 >= at.inMilliseconds;
}
nextPromptVisible.value = show;
}