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
@@ -90,8 +90,8 @@ loading…") or what went wrong, with Retry or Cancel when it helps. Chat and ac
|
|||||||
Tab), ready to type; it shows messages, and its "Activity" toggle adds
|
Tab), ready to type; it shows messages, and its "Activity" toggle adds
|
||||||
joins, ready, seeks and the like between them. Joins, leaves, chat and
|
joins, ready, seeks and the like between them. Joins, leaves, chat and
|
||||||
other news also pop up bottom left with the person's avatar; click a message to open the chat
|
other news also pop up bottom left with the person's avatar; click a message to open the chat
|
||||||
(Settings → Notifications → Chat messages: All, Only when paused or Off;
|
(Settings → Notifications → Chat messages: Only when paused by default,
|
||||||
messages always reach the chat). Unread chat shows as a count on the chat
|
or All, or Off; messages always reach the chat). Unread chat shows as a count on the chat
|
||||||
buttons and, in fullscreen with the controls hidden and the video paused,
|
buttons and, in fullscreen with the controls hidden and the video paused,
|
||||||
as a small chip top right.
|
as a small chip top right.
|
||||||
|
|
||||||
@@ -248,9 +248,10 @@ the same.
|
|||||||
|
|
||||||
Skipping: during the opening a "Skip OP" button floats in the corner (it
|
Skipping: during the opening a "Skip OP" button floats in the corner (it
|
||||||
jumps to the end of the opening), during the ending "Skip ED" (to the end
|
jumps to the end of the opening), during the ending "Skip ED" (to the end
|
||||||
of the ending only; what comes after is yours to skip). Any skip button
|
of the ending only; what comes after is yours to skip). "Skip OP" and
|
||||||
(also "Skip 1:30") shows for 5 seconds when it comes up, then only while
|
"Skip 1:30" show for 5 seconds when they come up, then only while the
|
||||||
the controls are up (move the mouse). The opening and
|
controls are up (move the mouse); "Skip ED" and "Next episode" show only
|
||||||
|
while the controls are up. The opening and
|
||||||
ending come from Jellyfin's intro / outro segments when your Jellyfin has
|
ending come from Jellyfin's intro / outro segments when your Jellyfin has
|
||||||
them (needs a segment plugin, e.g. Intro Skipper), else from chapter names
|
them (needs a segment plugin, e.g. Intro Skipper), else from chapter names
|
||||||
(OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening or
|
(OP, Opening, Intro / ED, Ending, Credits, Outro). Without an opening or
|
||||||
@@ -311,9 +312,9 @@ Watching:
|
|||||||
|
|
||||||
Episodes: the ⏮ / ⏭ buttons next to the controls (or `<` / `>`,
|
Episodes: the ⏮ / ⏭ buttons next to the controls (or `<` / `>`,
|
||||||
Shift+P / Shift+N) put up the previous or next episode for everyone. From
|
Shift+P / Shift+N) put up the previous or next episode for everyone. From
|
||||||
the ending, when it is known (see Skipping above), a "Next episode"
|
the ending (when it is known, see Skipping above; else in the last two
|
||||||
button floats in the corner; when it is not, nothing shows before the
|
minutes) a "Next episode" button floats in the corner while the controls
|
||||||
end. When an
|
are up. When an
|
||||||
episode ends, a card shows what is next with Play next; with "Play the
|
episode ends, a card shows what is next with Play next; with "Play the
|
||||||
next episode automatically" (Settings, on by default) it counts down 10 s
|
next episode automatically" (Settings, on by default) it counts down 10 s
|
||||||
first, and Cancel stops it on your screen (seeking back stops it for
|
first, and Cancel stops it on your screen (seeking back stops it for
|
||||||
|
|||||||
@@ -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`)
|
/// ED: Jellyfin Outro segment or ED-titled chapter, see `endingRange`)
|
||||||
/// when one is known inside [runtime]. Null otherwise: no guessed ending,
|
/// when one is known inside [runtime], else the last two minutes of
|
||||||
/// the end-of-episode card at the real end is the only prompt then.
|
/// [runtime] (all of it for shorter media). The pill shows only while the
|
||||||
Duration? nextPromptStart(JellyfinItem? item, Duration runtime, {Duration? endingStart}) {
|
/// 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) {
|
if (endingStart != null && endingStart > Duration.zero && endingStart < runtime) {
|
||||||
return endingStart;
|
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
|
/// 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):
|
/// Floating "Next episode ›" from the ED (or the last two minutes), while
|
||||||
/// faint at rest, lit on hover. Lives outside the auto-hiding controls, so
|
/// the controls are up (`PillGate`): faint at rest, lit on hover.
|
||||||
/// it shows in fullscreen and with the controls hidden.
|
|
||||||
class NextEpisodePill extends StatelessWidget {
|
class NextEpisodePill extends StatelessWidget {
|
||||||
final EpisodeLink episode;
|
final EpisodeLink episode;
|
||||||
final VoidCallback onPressed;
|
final VoidCallback onPressed;
|
||||||
|
|||||||
@@ -526,8 +526,8 @@ class SkipForwardButton extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Floating pill over the video (Next episode, Skip OP / ED / 1:30):
|
/// 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
|
/// faint at rest, lit on hover. Lives outside the auto-hiding controls
|
||||||
/// it shows in fullscreen and with the controls hidden.
|
/// (also in fullscreen); `PillGate` shows it with them.
|
||||||
class FloatingPill extends StatefulWidget {
|
class FloatingPill extends StatefulWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final String tooltip;
|
final String tooltip;
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ class NowPlaying {
|
|||||||
/// Portrait cover (2:3) and wide banner; '' when none.
|
/// Portrait cover (2:3) and wide banner; '' when none.
|
||||||
final String posterHash;
|
final String posterHash;
|
||||||
final String bannerHash;
|
final String bannerHash;
|
||||||
const NowPlaying({required this.title, this.episode, this.posterHash = '', this.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.
|
||||||
/// The art is a web video's thumbnail (the same picture as cover and
|
final bool thumbnail;
|
||||||
/// banner): shown wide, under the video's own name.
|
const NowPlaying(
|
||||||
bool get thumbnail => posterHash.isNotEmpty && posterHash == bannerHash;
|
{required this.title, this.episode, this.posterHash = '', this.bannerHash = '', this.thumbnail = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
@@ -21,8 +21,9 @@ class NowPlaying {
|
|||||||
other.title == title &&
|
other.title == title &&
|
||||||
other.episode == episode &&
|
other.episode == episode &&
|
||||||
other.posterHash == posterHash &&
|
other.posterHash == posterHash &&
|
||||||
other.bannerHash == bannerHash;
|
other.bannerHash == bannerHash &&
|
||||||
|
other.thumbnail == thumbnail;
|
||||||
|
|
||||||
@override
|
@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 '../files/name_parser.dart';
|
||||||
import '../jellyfin/session_media.dart';
|
import '../jellyfin/session_media.dart';
|
||||||
import 'now_playing.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 art = media['art'] is Map ? media['art'] as Map : const {};
|
||||||
final poster = art['poster'] is String ? art['poster'] as String : '';
|
final poster = art['poster'] is String ? art['poster'] as String : '';
|
||||||
final banner = art['banner'] is String ? art['banner'] 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);
|
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
|
final (title, episode) = ref != null
|
||||||
? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle)
|
? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle)
|
||||||
: fileTitle(name);
|
: fileTitle(name);
|
||||||
return NowPlaying(title: title, episode: episode, posterHash: poster, bannerHash: banner);
|
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
|
/// Jellyfin: [name] is the item's, [subtitle] "Series · S1E6" (or
|
||||||
/// "Series - S01E02") for episodes, the year for movies. Episodes give the
|
/// "Series - S01E02") for episodes, the year for movies. Episodes give the
|
||||||
/// series and "S1 E6 · Name"; anything else the name alone.
|
/// 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).
|
/// Neighbouring episodes this viewer can put up (own account only).
|
||||||
final previousEpisode = signal<EpisodeLink?>(null);
|
final previousEpisode = signal<EpisodeLink?>(null);
|
||||||
final nextEpisode = signal<EpisodeLink?>(null);
|
final nextEpisode = signal<EpisodeLink?>(null);
|
||||||
/// The floating "Next episode" pill from a known ending (Outro segment
|
/// The "Next episode" pill's window ([nextPromptStart]: the known ending,
|
||||||
/// or ED chapter, [nextPromptStart]) on.
|
/// else the last two minutes); the pill shows in it only while the
|
||||||
|
/// controls are up.
|
||||||
final nextPromptVisible = signal(false);
|
final nextPromptVisible = signal(false);
|
||||||
/// End-of-episode countdown; null when none runs here.
|
/// End-of-episode countdown; null when none runs here.
|
||||||
final autoAdvance = signal<AutoAdvance?>(null);
|
final autoAdvance = signal<AutoAdvance?>(null);
|
||||||
@@ -1690,7 +1691,7 @@ class SessionCoordinator {
|
|||||||
final item = (jellyfinPlan.peek() as OwnPlay?)?.item;
|
final item = (jellyfinPlan.peek() as OwnPlay?)?.item;
|
||||||
final at = nextPromptStart(item, Duration(milliseconds: (duration * 1000).round()),
|
final at = nextPromptStart(item, Duration(milliseconds: (duration * 1000).round()),
|
||||||
endingStart: playback.ending.peek()?.start);
|
endingStart: playback.ending.peek()?.start);
|
||||||
show = at != null && snap.position * 1000 >= at.inMilliseconds;
|
show = snap.position * 1000 >= at.inMilliseconds;
|
||||||
}
|
}
|
||||||
nextPromptVisible.value = show;
|
nextPromptVisible.value = show;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -668,8 +668,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
|
|
||||||
/// Jellyfin: the blocked card, the end-of-episode card (countdown) and
|
/// Jellyfin: the blocked card, the end-of-episode card (countdown) and
|
||||||
/// the "Next episode" pill; for any media the skip pill (Skip OP / ED /
|
/// the "Next episode" pill; for any media the skip pill (Skip OP / ED /
|
||||||
/// 1:30) above it ([SkipPillGate]). Outside the auto-hiding controls, so
|
/// 1:30) above it. Outside the auto-hiding controls; the cards stay up,
|
||||||
/// they stay up.
|
/// the pills show only with the controls ([PillGate]).
|
||||||
Widget _jellyfinOverlay() {
|
Widget _jellyfinOverlay() {
|
||||||
final coord = _coord;
|
final coord = _coord;
|
||||||
return Positioned.fill(
|
return Positioned.fill(
|
||||||
@@ -712,13 +712,21 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
spacing: 10,
|
spacing: 10,
|
||||||
children: [
|
children: [
|
||||||
if (skip != null)
|
if (skip != null)
|
||||||
SkipPillGate(
|
PillGate(
|
||||||
key: const ValueKey('skip-gate'),
|
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,
|
controlsShown: _controlsShown,
|
||||||
child: SkipPill(offer: skip, onPressed: () => coord.skip(skip)),
|
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
|
/// A floating pill's visibility: only while the controls are up (the
|
||||||
/// appears (a new kind: OP, ED, 1:30), then only while the controls are
|
/// mouse moved) or the mouse is on it. With [announce] it is also up for
|
||||||
/// up or the mouse is on it; gone with the offer (the parent drops it).
|
/// [firstShown] whenever [kind] changes (a new offer: Skip OP, Skip 1:30).
|
||||||
class SkipPillGate extends StatefulWidget {
|
/// Gone with the offer (the parent drops it).
|
||||||
|
class PillGate extends StatefulWidget {
|
||||||
static const firstShown = Duration(seconds: 5);
|
static const firstShown = Duration(seconds: 5);
|
||||||
final SkipOffer offer;
|
final Object kind;
|
||||||
|
final bool announce;
|
||||||
final ValueListenable<bool> controlsShown;
|
final ValueListenable<bool> controlsShown;
|
||||||
final Widget child;
|
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
|
@override
|
||||||
State<SkipPillGate> createState() => _SkipPillGateState();
|
State<PillGate> createState() => _PillGateState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SkipPillGateState extends State<SkipPillGate> {
|
class _PillGateState extends State<PillGate> {
|
||||||
SkipKind? _kind;
|
Object? _kind;
|
||||||
bool _fresh = false;
|
bool _fresh = false;
|
||||||
bool _hover = false;
|
bool _hover = false;
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
@@ -927,9 +943,9 @@ class _SkipPillGateState extends State<SkipPillGate> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(SkipPillGate old) {
|
void didUpdateWidget(PillGate old) {
|
||||||
super.didUpdateWidget(old);
|
super.didUpdateWidget(old);
|
||||||
if (widget.offer.kind != _kind) _arm();
|
if (widget.kind != _kind) _arm();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -939,10 +955,11 @@ class _SkipPillGateState extends State<SkipPillGate> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _arm() {
|
void _arm() {
|
||||||
_kind = widget.offer.kind;
|
_kind = widget.kind;
|
||||||
_fresh = true;
|
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = Timer(SkipPillGate.firstShown, () {
|
_fresh = widget.announce;
|
||||||
|
if (!_fresh) return;
|
||||||
|
_timer = Timer(PillGate.firstShown, () {
|
||||||
if (mounted) setState(() => _fresh = false);
|
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
|
/// A 2 px progress line at the bottom of the video while the controls
|
||||||
/// are hidden (both designs). Applies live.
|
/// are hidden (both designs). Applies live.
|
||||||
final progressLine = signal(true);
|
final progressLine = signal(true);
|
||||||
/// Chat message toasts. Applies live.
|
/// Chat message toasts (default: only when paused). Applies live.
|
||||||
final chatPopups = signal(ChatPopups.all);
|
final chatPopups = signal(ChatPopups.paused);
|
||||||
/// App sounds (`sfx/`): 0..1, 0 = silent.
|
/// App sounds (`sfx/`): 0..1, 0 = silent.
|
||||||
final soundVolume = signal(1.0);
|
final soundVolume = signal(1.0);
|
||||||
/// `SfxKind` names switched off one by one.
|
/// `SfxKind` names switched off one by one.
|
||||||
@@ -130,7 +130,7 @@ class AppSettings {
|
|||||||
final line = box.get(_keyProgressLine);
|
final line = box.get(_keyProgressLine);
|
||||||
if (line is bool) progressLine.value = line;
|
if (line is bool) progressLine.value = line;
|
||||||
final popups = box.get(_keyChatPopups);
|
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);
|
final vol = box.get(_keySoundVolume);
|
||||||
if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1);
|
if (vol is num) soundVolume.value = vol.toDouble().clamp(0, 1);
|
||||||
final off = box.get(_keySoundsOff);
|
final off = box.get(_keySoundsOff);
|
||||||
|
|||||||
@@ -301,9 +301,9 @@ void main() {
|
|||||||
a.toggleReady();
|
a.toggleReady();
|
||||||
await _poll(() => all.every((x) => x.playback.player.state.playing), 'playing again');
|
await _poll(() => all.every((x) => x.playback.player.state.playing), 'playing again');
|
||||||
|
|
||||||
// No known ending (no Outro segment, no ED chapter): no floating
|
// Account holders see the "Next episode" prompt (this item is
|
||||||
// "Next episode" prompt mid-episode, only the card at the end.
|
// shorter than its 2 min lead); the guest cannot put it up.
|
||||||
expect(a.nextPromptVisible.peek(), isFalse);
|
await _poll(() => a.nextPromptVisible.peek(), 'next prompt');
|
||||||
expect(b.nextPromptVisible.peek(), isFalse);
|
expect(b.nextPromptVisible.peek(), isFalse);
|
||||||
|
|
||||||
// Alice and the guest pick English; it carries to the next episode.
|
// Alice and the guest pick English; it carries to the next episode.
|
||||||
|
|||||||
@@ -321,9 +321,9 @@ void main() {
|
|||||||
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7');
|
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('no floating prompt without a known ending', () {
|
test('the floating prompt starts two minutes before the end', () {
|
||||||
expect(nextPromptStart(null, const Duration(minutes: 24)), isNull);
|
expect(nextPromptStart(null, const Duration(minutes: 24)), const Duration(minutes: 22));
|
||||||
expect(nextPromptStart(null, const Duration(seconds: 90)), isNull);
|
expect(nextPromptStart(null, const Duration(seconds: 90)), Duration.zero);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('one auto-advance swapper: the lowest id among members with their own account', () {
|
test('one auto-advance swapper: the lowest id among members with their own account', () {
|
||||||
|
|||||||
@@ -154,14 +154,14 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('next episode prompt', () {
|
group('next episode prompt', () {
|
||||||
test('at the ED start when known, else none (never a guessed time)', () {
|
test('at the ED start when known, else the last two minutes', () {
|
||||||
expect(nextPromptStart(null, m(24), endingStart: m(20)), m(20));
|
expect(nextPromptStart(null, m(24), endingStart: m(20)), m(20));
|
||||||
expect(nextPromptStart(null, m(24)), isNull);
|
expect(nextPromptStart(null, m(24)), m(22));
|
||||||
expect(nextPromptStart(null, m(24), endingStart: null), isNull);
|
expect(nextPromptStart(null, m(24), endingStart: null), m(22));
|
||||||
// Nonsense ending starts count as unknown.
|
// Nonsense ending starts fall back.
|
||||||
expect(nextPromptStart(null, m(24), endingStart: m(25)), isNull);
|
expect(nextPromptStart(null, m(24), endingStart: m(25)), m(22));
|
||||||
expect(nextPromptStart(null, m(24), endingStart: Duration.zero), isNull);
|
expect(nextPromptStart(null, m(24), endingStart: Duration.zero), m(22));
|
||||||
expect(nextPromptStart(null, s(90)), isNull);
|
expect(nextPromptStart(null, s(90)), Duration.zero);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -42,4 +42,25 @@ void main() {
|
|||||||
const NowPlaying(title: 'Show', episode: 'S1 E2 · The Pilot', posterHash: 'aaaa', bannerHash: 'bbbb'));
|
const NowPlaying(title: 'Show', episode: 'S1 E2 · The Pilot', posterHash: 'aaaa', bannerHash: 'bbbb'));
|
||||||
expect(nowPlayingOf({'name': 'Show - 12.mkv'}), const NowPlaying(title: 'Show', episode: 'E12'));
|
expect(nowPlayingOf({'name': 'Show - 12.mkv'}), const NowPlaying(title: 'Show', episode: 'E12'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('web videos: name as is and wide from the start, before the thumbnail', () {
|
||||||
|
const title = 'Frieren - 05 | reaction';
|
||||||
|
// Everyone opens it, passed on by a member, fetched by the server.
|
||||||
|
for (final media in [
|
||||||
|
{'name': title, 'url': 'https://www.youtube.com/watch?v=x'},
|
||||||
|
{'name': title, 'relay': {'source': 'm2', 'live': true}},
|
||||||
|
{'name': title, 'relay': {'fetch': true}},
|
||||||
|
]) {
|
||||||
|
expect(nowPlayingOf(media), const NowPlaying(title: title, thumbnail: true));
|
||||||
|
expect(nowPlayingOf({...media, 'art': {'poster': 'aa', 'banner': 'aa'}}),
|
||||||
|
const NowPlaying(title: title, posterHash: 'aa', bannerHash: 'aa', thumbnail: true));
|
||||||
|
}
|
||||||
|
// Files (a link to one, a relayed one) and Jellyfin keep their parsing.
|
||||||
|
expect(nowPlayingOf({'name': 'Show - 12.mkv', 'url': 'https://cdn.example/Show%20-%2012.mkv'}),
|
||||||
|
const NowPlaying(title: 'Show', episode: 'E12'));
|
||||||
|
expect(nowPlayingOf({'name': 'Show - 12.mkv', 'relay': {'source': 'm2'}}),
|
||||||
|
const NowPlaying(title: 'Show', episode: 'E12'));
|
||||||
|
expect(nowPlayingOf({'name': 'Show - 12', 'relay': {}, 'art': {'poster': 'p', 'banner': 'b'}}),
|
||||||
|
const NowPlaying(title: 'Show', episode: 'E12', posterHash: 'p', bannerHash: 'b'));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
+17
-11
@@ -744,9 +744,9 @@ Client side (`lib/src/jellyfin/session_media.dart`, `progress.dart`,
|
|||||||
`/Shows/{id}/Episodes` call; `episodeBefore` / `episodeAfter`) gives the
|
`/Shows/{id}/Episodes` call; `episodeBefore` / `episodeAfter`) gives the
|
||||||
previous and next episode. The controls show previous / next buttons
|
previous and next episode. The controls show previous / next buttons
|
||||||
(also `<` `>`, Shift+P / Shift+N); from `nextPromptStart` (the ending's
|
(also `<` `>`, Shift+P / Shift+N); from `nextPromptStart` (the ending's
|
||||||
start, see "Skip rules") a floating "Next episode" pill shows outside
|
start when one is known, see "Skip rules", else the last 2 min of the
|
||||||
the auto-hiding controls. Without a known ending there is no pill (never
|
runtime) a floating "Next episode" pill shows while the controls are up
|
||||||
a guessed time); the end card at `finished` is the only prompt. Auto-advance still waits for `finished`. Any
|
(see "Session controls"). Auto-advance still waits for `finished`. Any
|
||||||
of them swaps to that episode's ref with the clicker's account. Guests
|
of them swaps to that episode's ref with the clicker's account. Guests
|
||||||
(relay, blocked) see none of it.
|
(relay, blocked) see none of it.
|
||||||
- **Auto-advance** (Settings, on by default): at `finished` with a known
|
- **Auto-advance** (Settings, on by default): at `finished` with a known
|
||||||
@@ -984,7 +984,7 @@ always scrolls the page). Space never presses a focused button
|
|||||||
slides in, stays 5 s, then slides out and collapses its gap. A click on
|
slides in, stays 5 s, then slides out and collapses its gap. A click on
|
||||||
a chat toast opens the chat; the others let clicks through. Chat toasts
|
a chat toast opens the chat; the others let clicks through. Chat toasts
|
||||||
follow `AppSettings.chatPopups` (Settings → Notifications: All, Only
|
follow `AppSettings.chatPopups` (Settings → Notifications: All, Only
|
||||||
when paused, Off); the message is in the chat either way.
|
when paused (default), Off); the message is in the chat either way.
|
||||||
|
|
||||||
Settings persist in two Hive boxes: `connect_form` (last form, recent
|
Settings persist in two Hive boxes: `connect_form` (last form, recent
|
||||||
servers and links, the profile picture and colour; saved even when a join
|
servers and links, the profile picture and colour; saved even when a join
|
||||||
@@ -1056,10 +1056,11 @@ keeps its row at the top). A drag sends no hover events, so the bar keeps
|
|||||||
the controls up through a guarded dynamic `onHover()` on media_kit's
|
the controls up through a guarded dynamic `onHover()` on media_kit's
|
||||||
private controls state (`keepVideoControlsAwake`). Pills (Skip OP / ED /
|
private controls state (`keepVideoControlsAwake`). Pills (Skip OP / ED /
|
||||||
1:30, Next episode) and cards live outside the auto-hiding controls. The
|
1:30, Next episode) and cards live outside the auto-hiding controls. The
|
||||||
Next episode pill and the cards stay up; the skip pill (`SkipPillGate`)
|
cards stay up; a pill (`PillGate`) shows only while the controls are up
|
||||||
shows for 5 s when an offer of a new kind appears, then only while the
|
(`ControlsHoverProbe` for this design, `UoscControls.shown` for uosc) or
|
||||||
controls are up (`ControlsHoverProbe` for this design, `UoscControls.shown`
|
the mouse is on it, and Skip OP / Skip 1:30 also for 5 s when an offer of
|
||||||
for uosc) or the mouse is on it. Volume (`controls/volume.dart`, both
|
a new kind appears (Skip ED and Next episode never without the controls).
|
||||||
|
Volume (`controls/volume.dart`, both
|
||||||
designs): mpv's `volume-max` is pinned to `PlaybackController.maxVolume`
|
designs): mpv's `volume-max` is pinned to `PlaybackController.maxVolume`
|
||||||
(150) per player; `VolumeBar` runs 0..150 with a tick at 100 and the part
|
(150) per player; `VolumeBar` runs 0..150 with a tick at 100 and the part
|
||||||
above in amber; the wheel and ↑ / ↓ step 5 %, clamped.
|
above in amber; the wheel and ↑ / ↓ step 5 %, clamped.
|
||||||
@@ -1193,6 +1194,11 @@ testers land back on stable. `0.0.0-dev` (a dev run) checks nothing.
|
|||||||
poster cap when larger; else an AniList title search on the parsed
|
poster cap when larger; else an AniList title search on the parsed
|
||||||
release name) and sends `art`, so guests without Jellyfin get covers too.
|
release name) and sends `art`, so guests without Jellyfin get covers too.
|
||||||
`coordinator.nowPlaying` derives title, episode line and art hashes from
|
`coordinator.nowPlaying` derives title, episode line and art hashes from
|
||||||
`state.media` (`now_playing_info.dart`); poster and banner of one hash
|
`state.media` (`now_playing_info.dart`). A web video
|
||||||
mark a thumbnail (`NowPlaying.thumbnail`): the media name as is, no
|
(`NowPlaying.thumbnail`) shows the media name as is, no episode line, a
|
||||||
episode line, a 16:9 cover in the controls and none on the start card.
|
16:9 cover in the controls (a neutral placeholder until the art is up)
|
||||||
|
and none on the start card. Art up: poster and banner of one hash mark
|
||||||
|
it. Before the art: `isWebVideo` tells it from the media itself (an
|
||||||
|
http(s) `url` without a media file extension, or a relayed, non-Jellyfin
|
||||||
|
media whose name has no file extension: a page's title), so the layout
|
||||||
|
never switches when the thumbnail lands.
|
||||||
Reference in new issue
Block a user