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:
1 parent
27b4ec2c96
commit
2a7b553597
14 files changed
+149
-78
No files matched your search
@@ -318,15 +318,17 @@ Future<({EpisodeLink? previous, EpisodeLink? next})> findAdjacentEpisodes(OwnPla
|
||||
}
|
||||
}
|
||||
|
||||
/// When the floating "Next episode" pill appears: at [endingStart] (the
|
||||
/// Where the "Next episode" pill's window starts: at [endingStart] (the
|
||||
/// ED: Jellyfin Outro segment or ED-titled chapter, see `endingRange`)
|
||||
/// when one is known inside [runtime]. Null otherwise: no guessed ending,
|
||||
/// the end-of-episode card at the real end is the only prompt then.
|
||||
Duration? nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? endingStart}) {
|
||||
/// when one is known inside [runtime], else the last two minutes of
|
||||
/// [runtime] (all of it for shorter media). The pill shows only while the
|
||||
/// controls are up (`PillGate`), so a guessed window costs nothing.
|
||||
Duration nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? endingStart}) {
|
||||
if (endingStart != null && endingStart > Duration.zero && endingStart < runtime) {
|
||||
return endingStart;
|
||||
}
|
||||
return null;
|
||||
const lead = Duration(minutes: 2);
|
||||
return runtime > lead ? runtime - lead : Duration.zero;
|
||||
}
|
||||
|
||||
/// Who performs the auto-advance swap, so several counting clients do not
|
||||
|
||||
@@ -36,9 +36,8 @@ class EpisodeSkipButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Floating "Next episode ›" from the ED (or the last two minutes):
|
||||
/// faint at rest, lit on hover. Lives outside the auto-hiding controls, so
|
||||
/// it shows in fullscreen and with the controls hidden.
|
||||
/// Floating "Next episode ›" from the ED (or the last two minutes), while
|
||||
/// the controls are up (`PillGate`): faint at rest, lit on hover.
|
||||
class NextEpisodePill extends StatelessWidget {
|
||||
final EpisodeLink episode;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@@ -526,8 +526,8 @@ class SkipForwardButton extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// Floating pill over the video (Next episode, Skip OP / ED / 1:30):
|
||||
/// faint at rest, lit on hover. Lives outside the auto-hiding controls, so
|
||||
/// it shows in fullscreen and with the controls hidden.
|
||||
/// faint at rest, lit on hover. Lives outside the auto-hiding controls
|
||||
/// (also in fullscreen); `PillGate` shows it with them.
|
||||
class FloatingPill extends StatefulWidget {
|
||||
final String label;
|
||||
final String tooltip;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -668,8 +668,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
|
||||
/// 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 ([SkipPillGate]). Outside the auto-hiding controls, so
|
||||
/// they stay up.
|
||||
/// 1:30) above it. Outside the auto-hiding controls; the cards stay up,
|
||||
/// the pills show only with the controls ([PillGate]).
|
||||
Widget _jellyfinOverlay() {
|
||||
final coord = _coord;
|
||||
return Positioned.fill(
|
||||
@@ -712,13 +712,21 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
spacing: 10,
|
||||
children: [
|
||||
if (skip != null)
|
||||
SkipPillGate(
|
||||
PillGate(
|
||||
key: const ValueKey('skip-gate'),
|
||||
offer: skip,
|
||||
kind: skip.kind,
|
||||
// Skip ED: no announcing, only with the controls.
|
||||
announce: skip.kind != SkipKind.ending,
|
||||
controlsShown: _controlsShown,
|
||||
child: SkipPill(offer: skip, onPressed: () => coord.skip(skip)),
|
||||
),
|
||||
if (showNext) NextEpisodePill(episode: next, onPressed: coord.playNextEpisode),
|
||||
if (showNext)
|
||||
PillGate(
|
||||
key: const ValueKey('next-gate'),
|
||||
kind: next,
|
||||
controlsShown: _controlsShown,
|
||||
child: NextEpisodePill(episode: next, onPressed: coord.playNextEpisode),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -900,22 +908,30 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The skip pill's visibility: up for [firstShown] after an offer
|
||||
/// appears (a new kind: OP, ED, 1:30), then only while the controls are
|
||||
/// up or the mouse is on it; gone with the offer (the parent drops it).
|
||||
class SkipPillGate extends StatefulWidget {
|
||||
/// A floating pill's visibility: only while the controls are up (the
|
||||
/// mouse moved) or the mouse is on it. With [announce] it is also up for
|
||||
/// [firstShown] whenever [kind] changes (a new offer: Skip OP, Skip 1:30).
|
||||
/// Gone with the offer (the parent drops it).
|
||||
class PillGate extends StatefulWidget {
|
||||
static const firstShown = Duration(seconds: 5);
|
||||
final SkipOffer offer;
|
||||
final Object kind;
|
||||
final bool announce;
|
||||
final ValueListenable<bool> controlsShown;
|
||||
final Widget child;
|
||||
const SkipPillGate({super.key, required this.offer, required this.controlsShown, required this.child});
|
||||
const PillGate({
|
||||
super.key,
|
||||
required this.kind,
|
||||
this.announce = false,
|
||||
required this.controlsShown,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SkipPillGate> createState() => _SkipPillGateState();
|
||||
State<PillGate> createState() => _PillGateState();
|
||||
}
|
||||
|
||||
class _SkipPillGateState extends State<SkipPillGate> {
|
||||
SkipKind? _kind;
|
||||
class _PillGateState extends State<PillGate> {
|
||||
Object? _kind;
|
||||
bool _fresh = false;
|
||||
bool _hover = false;
|
||||
Timer? _timer;
|
||||
@@ -927,9 +943,9 @@ class _SkipPillGateState extends State<SkipPillGate> {
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SkipPillGate old) {
|
||||
void didUpdateWidget(PillGate old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (widget.offer.kind != _kind) _arm();
|
||||
if (widget.kind != _kind) _arm();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -939,10 +955,11 @@ class _SkipPillGateState extends State<SkipPillGate> {
|
||||
}
|
||||
|
||||
void _arm() {
|
||||
_kind = widget.offer.kind;
|
||||
_fresh = true;
|
||||
_kind = widget.kind;
|
||||
_timer?.cancel();
|
||||
_timer = Timer(SkipPillGate.firstShown, () {
|
||||
_fresh = widget.announce;
|
||||
if (!_fresh) return;
|
||||
_timer = Timer(PillGate.firstShown, () {
|
||||
if (mounted) setState(() => _fresh = false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,8 +70,8 @@ class AppSettings {
|
||||
/// A 2 px progress line at the bottom of the video while the controls
|
||||
/// are hidden (both designs). Applies live.
|
||||
final progressLine = signal(true);
|
||||
/// Chat message toasts. Applies live.
|
||||
final chatPopups = signal(ChatPopups.all);
|
||||
/// Chat message toasts (default: only when paused). Applies live.
|
||||
final chatPopups = signal(ChatPopups.paused);
|
||||
/// App sounds (`sfx/`): 0..1, 0 = silent.
|
||||
final soundVolume = signal(1.0);
|
||||
/// `SfxKind` names switched off one by one.
|
||||
@@ -130,7 +130,7 @@ class AppSettings {
|
||||
final line = box.get(_keyProgressLine);
|
||||
if (line is bool) progressLine.value = line;
|
||||
final popups = box.get(_keyChatPopups);
|
||||
chatPopups.value = ChatPopups.values.where((m) => m.name == popups).firstOrNull ?? ChatPopups.all;
|
||||
chatPopups.value = ChatPopups.values.where((m) => m.name == popups).firstOrNull ?? ChatPopups.paused;
|
||||
final vol = box.get(_keySoundVolume);
|
||||
if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1);
|
||||
final off = box.get(_keySoundsOff);
|
||||
|
||||
Reference in new issue
Block a user