Player: now-playing title with cover, start banner, one status pill; media art for everyone

The swapper sends the media's poster and banner once (`art`, Jellyfin
images or AniList by parsed name); state carries their hashes and every
member fetches them from /i/ like avatars. state.media.swapBy names who
put the media up. uosc and the standard bar show cover, title and
episode; a banner card shows before playback starts. The top-left health
pill and the waiting pill become one frosted status pill in plain words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 01:19:34 +03:00
1 parent d092ea371a
commit b534c8c7ea
29 files changed
+1892 -236

No files matched your search

@@ -6,7 +6,8 @@ import 'package:signals/signals_flutter.dart';
import '../../state/settings.dart';
// The mpv-style (uosc) controls frame: a full-width timeline at the
// bottom with a row of flat buttons above it, a corner widget top right.
// bottom with a row of flat buttons above it, the title top left and a
// corner widget top right.
// Mouse movement shows them; after [UoscControls.hideAfter] without any
// they fade out, the timeline shrinks to a 2 px line (gone when "Progress
// line when hidden" is off) and the cursor hides. Our own frame: no
@@ -20,6 +21,9 @@ class UoscControls extends StatefulWidget {
final List<Widget> right;
/// Top-right corner (chat, back to the library), shown with the controls.
final Widget? top;
/// Top left (what is playing), shown with the controls; gets the width
/// [top] leaves.
final Widget? topLeft;
final VoidCallback? onDoubleTap;
final Duration hideAfter;
const UoscControls({
@@ -28,6 +32,7 @@ class UoscControls extends StatefulWidget {
this.left = const [],
this.right = const [],
this.top,
this.topLeft,
this.onDoubleTap,
this.hideAfter = const Duration(seconds: 3),
});
@@ -136,13 +141,54 @@ class UoscControlsState extends State<UoscControls> {
),
),
),
if (widget.top != null)
if (widget.topLeft != null)
Positioned(
left: 0,
right: 0,
top: 0,
height: 120,
child: IgnorePointer(
child: AnimatedOpacity(
duration: fade,
opacity: visible ? 1 : 0,
child: const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x8C000000), Color(0x00000000)],
),
),
),
),
),
),
if (widget.top != null || widget.topLeft != null)
Positioned(
top: 0,
left: 0,
right: 0,
child: IgnorePointer(
key: const ValueKey('uosc-top'),
ignoring: !visible,
child: AnimatedOpacity(duration: fade, opacity: visible ? 1 : 0, child: widget.top),
child: AnimatedOpacity(
duration: fade,
opacity: visible ? 1 : 0,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: widget.topLeft == null
? const SizedBox.shrink()
: Align(alignment: AlignmentDirectional.centerStart, child: widget.topLeft),
),
),
?widget.top,
],
),
),
),
),
Positioned(
+74 -10
View File
@@ -9,7 +9,8 @@ import 'package:sync_relay_core/core.dart';
import '../update/updater.dart' show appVersion;
// AniList airing schedule (public GraphQL, no login): every episode airing
// in a time range, with covers, banners and popularity.
// in a time range, with covers, banners and popularity; and a title search
// (cover + banner of a show, for the media art of files and links).
//
// AniList allows ~90 requests/min (sometimes degraded to 30), so the raw
// pages are cached: in memory for 10 min (tab switches) and on disk for an
@@ -76,6 +77,35 @@ class Airing {
String toString() => 'Airing($anilistId "$title" ep $episode @ $airingAt)';
}
/// An anime found by title ([AniListClient.searchMedia]).
class AniListMedia {
final int id;
/// English title, else romaji.
final String title;
/// Cover (portrait) URL: extraLarge, else large.
final String? cover;
/// The smaller cover, for when [cover] is too big to send on.
final String? coverSmall;
final String? banner;
const AniListMedia({required this.id, required this.title, this.cover, this.coverSmall, this.banner});
static AniListMedia? fromJson(Object? m) {
if (m is! Map || m['id'] is! int) return null;
final t = m['title'] is Map ? m['title'] as Map : const {};
final romaji = t['romaji'] is String ? t['romaji'] as String : '';
final english = t['english'] is String ? t['english'] as String : '';
final cov = m['coverImage'] is Map ? m['coverImage'] as Map : const {};
final large = cov['large'] is String ? cov['large'] as String : null;
return AniListMedia(
id: m['id'] as int,
title: english.isNotEmpty ? english : romaji,
cover: cov['extraLarge'] is String ? cov['extraLarge'] as String : large,
coverSmall: large,
banner: m['bannerImage'] is String ? m['bannerImage'] as String : null,
);
}
}
/// "#e4a15d" -> opaque [Color]; anything else -> null.
Color? parseHexColor(Object? s) {
if (s is! String || s.length != 7 || !s.startsWith('#')) return null;
@@ -217,11 +247,19 @@ media{id title{romaji english} coverImage{large color} bannerImage isAdult popul
}
Future<Map> _page(int from, int to, int page) async {
final (data, status, j) = await _graphql(query, {'from': from - 1, 'to': to, 'page': page});
final p = data?['Page'];
if (p is! Map) {
throw AniListException('AniList: bad response${_errorText(j)}', statusCode: status);
}
return p;
}
/// One GraphQL request: `data` (null when missing), status and body. A
/// 429 waits out Retry-After once; other non-200s throw.
Future<(Map?, int, Object?)> _graphql(String query, Map<String, Object?> variables) async {
for (var attempt = 0;; attempt++) {
final (status, body, retryAfter) = await _post(jsonEncode({
'query': query,
'variables': {'from': from - 1, 'to': to, 'page': page},
}));
final (status, body, retryAfter) = await _post(jsonEncode({'query': query, 'variables': variables}));
if (status == 429 && attempt == 0) {
final wait = retryAfter ?? 60;
_log.info('AniList rate limited, retrying in ${wait}s');
@@ -235,11 +273,37 @@ media{id title{romaji english} coverImage{large color} bannerImage isAdult popul
if (status != 200) {
throw AniListException('AniList HTTP $status${_errorText(j)}', statusCode: status);
}
final data = j is Map && j['data'] is Map ? (j['data'] as Map)['Page'] : null;
if (data is! Map) {
throw AniListException('AniList: bad response${_errorText(j)}', statusCode: status);
}
return data;
final data = j is Map && j['data'] is Map ? j['data'] as Map : null;
return (data, status, j);
}
}
static const searchQuery = r'''
query($search:String){Media(search:$search,type:ANIME,isAdult:false){id title{romaji english}
coverImage{extraLarge large} bannerImage}}''';
final _search = <String, Future<AniListMedia?>>{};
/// AniList's best anime match for [title], or null when there is none.
/// One request per title for this client's life (a failure is not kept,
/// so a later call asks again).
Future<AniListMedia?> searchMedia(String title) {
final key = title.trim();
if (key.isEmpty) return Future.value(null);
return _search[key] ??= _searchMedia(key).catchError((Object e) {
_search.remove(key);
throw e;
});
}
Future<AniListMedia?> _searchMedia(String title) async {
try {
final (data, _, _) = await _graphql(searchQuery, {'search': title});
return AniListMedia.fromJson(data?['Media']);
} on AniListException catch (e) {
// No match is a 404 with "Not Found." in errors.
if (e.statusCode == 404) return null;
rethrow;
}
}
@@ -0,0 +1,95 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart';
import '../files/name_parser.dart';
import '../schedule/anilist.dart';
// Art for media this client swapped in (`art`, see PROTOCOL.md "Media
// art"): Jellyfin images with this install's account, else an AniList
// title search. Everything here fails quietly to "no art".
/// Poster and banner bytes; either may be null.
typedef ArtBytes = ({Uint8List? poster, Uint8List? banner});
/// Series poster (the item's own for movies) and series/item backdrop,
/// else its Banner image, scaled by Jellyfin to fit the caps.
Future<ArtBytes> jellyfinArt(JellyfinClient client, String itemId,
{Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async {
final item = await client.item(itemId).timeout(const Duration(seconds: 10));
final seriesPoster = item.seriesId != null && item.seriesPrimaryImageTag != null
? JellyfinImageRef(item.seriesId!, JellyfinImageType.primary, item.seriesPrimaryImageTag!)
: null;
final poster = seriesPoster ?? item.image(JellyfinImageType.primary);
var banner = item.image(JellyfinImageType.backdrop);
if (banner == null) {
var owner = item;
if (item.seriesId != null && item.ownImage(JellyfinImageType.banner) == null) {
try {
owner = await client.item(item.seriesId!).timeout(const Duration(seconds: 10));
} catch (_) {}
}
banner = owner.ownImage(JellyfinImageType.banner);
}
final headers = client.authHeaders;
final (p, b) = await (
poster == null
? Future<Uint8List?>.value()
: fetch(client.imageUrl(poster, maxHeight: 450, quality: 85), headers, RelayConstants.maxPosterBytes),
banner == null
? Future<Uint8List?>.value()
: fetch(client.imageUrl(banner, maxWidth: 1280, quality: 85), headers, RelayConstants.maxBannerBytes),
).wait;
return (poster: p, banner: b);
}
/// The series of release name [name] on AniList: cover and banner; null
/// when the name says nothing or AniList knows no match.
Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
{Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async {
final series = parseReleaseName(name).series.trim();
if (series.isEmpty) return null;
final match = await aniList.searchMedia(series);
if (match == null) return null;
Future<Uint8List?> get(String? url, int maxBytes) async {
final uri = url == null ? null : Uri.tryParse(url);
return uri == null ? null : fetch(uri, const {}, maxBytes);
}
final (cover, banner) = await (
get(match.cover, RelayConstants.maxPosterBytes),
get(match.banner, RelayConstants.maxBannerBytes),
).wait;
final poster = cover ?? (match.coverSmall == match.cover ? null : await get(match.coverSmall, RelayConstants.maxPosterBytes));
return (poster: poster, banner: banner);
}
/// GET [url]: the bytes when 200, at most [maxBytes] and a known image
/// type (see `avatarImageType`); else null.
Future<Uint8List?> fetchImage(Uri url, Map<String, String> headers, int maxBytes) async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 10);
try {
final req = await client.getUrl(url);
headers.forEach(req.headers.set);
final resp = await req.close().timeout(const Duration(seconds: 15));
if (resp.statusCode != 200 || resp.contentLength > maxBytes) {
await resp.drain<void>().catchError((_) {});
return null;
}
final builder = BytesBuilder(copy: false);
await for (final chunk in resp.timeout(const Duration(seconds: 15))) {
builder.add(chunk);
if (builder.length > maxBytes) return null;
}
final bytes = builder.takeBytes();
return avatarImageType(bytes) == null ? null : bytes;
} catch (e) {
debugPrint('art: fetch failed: $e');
return null;
} finally {
client.close(force: true);
}
}
@@ -0,0 +1,24 @@
/// What the player shows about the current media: a title, an episode line
/// and art. Art is by content hash (`state.media.art`), fetched by everyone
/// (guests included) from the server they dialled; '' when there is none.
class NowPlaying {
/// Series name for episodes, else the movie, file or link name.
final String title;
/// "S2 E4 · Episode title", "E12", or null (movies, links).
final String? episode;
/// 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 = ''});
@override
bool operator ==(Object other) =>
other is NowPlaying &&
other.title == title &&
other.episode == episode &&
other.posterHash == posterHash &&
other.bannerHash == bannerHash;
@override
int get hashCode => Object.hash(title, episode, posterHash, bannerHash);
}
@@ -0,0 +1,56 @@
import '../files/name_parser.dart';
import '../jellyfin/session_media.dart';
import 'now_playing.dart';
// Title and episode line of `state.media` (pure; see [NowPlaying]).
// "S1E6" (pickFor) or "S01E02".
final _episodeLabel = RegExp(r'^S(\d{1,3})\s?E(\d{1,4})$', caseSensitive: false);
final _year = RegExp(r'^(?:19|20)\d{2}$');
final _ext = RegExp(r'\.(?=[a-z0-9]*[a-z])[a-z0-9]{2,4}$', caseSensitive: false);
/// What the player shows for [media] (`state.media`); null with nothing up.
NowPlaying? nowPlayingOf(Map<String, Object?>? media) {
if (media == null) return null;
final name = media['name'] is String ? media['name'] as String : '';
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 : '';
final ref = refOfStateMedia(media);
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);
}
/// 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.
(String, String?) jellyfinTitle(String name, String? subtitle) {
final sub = subtitle?.trim() ?? '';
if (sub.isEmpty || _year.hasMatch(sub)) return (name, null);
// The label is after the last separator (or the whole subtitle).
final cut = RegExp(r' [·-] ').allMatches(sub).lastOrNull;
final m = _episodeLabel.firstMatch(cut == null ? sub : sub.substring(cut.end).trim());
final series = m == null ? sub : (cut == null ? '' : sub.substring(0, cut.start).trim());
final label = m == null ? null : 'S${int.parse(m.group(1)!)} E${int.parse(m.group(2)!)}';
if (series.isEmpty) return (name, label);
final episode = [?label, if (name.isNotEmpty) name].join(' · ');
return (series, episode.isEmpty ? null : episode);
}
/// Files and links: the release name's series and "S1 E3", "E25" or its
/// extra marker (NCOP1, OVA 2); the name minus extension when it says
/// nothing better.
(String, String?) fileTitle(String name) {
final p = parseReleaseName(name);
final series = p.series.trim();
if (series.isEmpty) return (name.replaceFirst(_ext, ''), null);
final String? episode;
if (p.episode != null) {
episode = p.season != null ? 'S${p.season} E${p.episode}' : 'E${p.episode}';
} else {
episode = p.extra;
}
return (series, episode);
}
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/widgets.dart';
@@ -11,6 +12,7 @@ import '../jellyfin/session_media.dart';
import '../jellyfin/track_prefs.dart';
import '../playback/media_marks.dart';
import '../playback/playback_controller.dart';
import '../schedule/anilist.dart';
import '../screens/home/picked_media.dart';
import '../sfx/sfx.dart';
import '../state/settings.dart';
@@ -20,6 +22,9 @@ import '../sync/sync_client.dart';
import '../sync/user_seek.dart';
import 'health.dart';
import 'idle.dart';
import 'media_art.dart';
import 'now_playing.dart';
import 'now_playing_info.dart';
/// Thrown when a connect is superseded by cancel/leave before it finished.
class ConnectCancelled implements Exception {
@@ -146,6 +151,8 @@ class SessionCoordinator {
final SyncClient sync = SyncClient();
/// Member pictures by `avatarHash`, fetched from the server.
late final AvatarCache avatars = AvatarCache(sync);
/// The current media's poster and banner by `state.media.art` hash.
late final ArtCache art = ArtCache(sync);
final PlaybackController playback = PlaybackController();
final StateDiff diff = StateDiff();
@@ -160,6 +167,9 @@ class SessionCoordinator {
/// The current media's Jellyfin ref (null for links and files).
final currentRef = signal<JellyfinRef?>(null);
/// Title, episode line and art hashes of the current media; null with
/// nothing up.
late final nowPlaying = computed<NowPlaying?>(() => nowPlayingOf(serverState.value?.media));
/// How this viewer plays [currentRef]; null while resolving.
final jellyfinPlan = signal<JellyfinPlan?>(null);
/// Set when this viewer cannot play the Jellyfin media: no account and
@@ -221,6 +231,10 @@ class SessionCoordinator {
String _wsHost = '';
int _wsPort = 0;
_RelayOrigin? _pendingOrigin;
// This client's last swap, until a state shows it landed (a newer
// mediaId than [after] with this member as `swapBy`): its art goes up then.
({HelloMedia media, int after})? _pendingArt;
AniListClient? _aniList;
// What this member serves for media [$1]; survives a reconnect (the
// state then looks new again).
(int, _RelayOrigin)? _activeOrigin;
@@ -386,7 +400,7 @@ class SessionCoordinator {
final media = params.media ??
(params.linkUrl.isEmpty ? null : await mediaForLink(jellyfin, params.linkUrl));
if (epoch != _epoch) throw const ConnectCancelled();
if (media != null) sync.sendSwap(media);
if (media != null) _swap(media);
_startTicker();
} catch (e) {
if (epoch != _epoch) throw const ConnectCancelled();
@@ -522,6 +536,7 @@ class SessionCoordinator {
void _onState(StateBroadcast r) {
_lastStateAt = DateTime.now().toUtc();
serverState.value = r;
_maybeSendArt(r);
_queuedState = r;
if (_applying) return;
_applying = true;
@@ -984,6 +999,43 @@ class SessionCoordinator {
sync.sendKick(memberId, reason: reason);
}
void _swap(HelloMedia media) {
_pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0);
sync.sendSwap(media);
}
void _maybeSendArt(StateBroadcast r) {
final pending = _pendingArt;
if (pending == null || r.mediaId <= pending.after) return;
if (sync.memberId.isEmpty || r.media?['swapBy'] != sync.memberId) return;
_pendingArt = null;
unawaited(_sendArt(r.mediaId, pending.media));
}
/// Fetches the art of [media] (this client's swap, now [mediaId]) and
/// sends it; quietly nothing on any failure.
Future<void> _sendArt(int mediaId, HelloMedia media) async {
try {
final ref = media.jellyfin;
final client = ref == null ? null : jellyfin.clientFor(ref.serverId);
final ArtBytes? bytes;
if (ref != null && client != null) {
bytes = await jellyfinArt(client, ref.itemId);
} else {
final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1;
bytes = await aniListArt(_aniList ??= AniListClient(), name);
}
final poster = bytes?.poster, banner = bytes?.banner;
if (poster == null && banner == null) return;
if (serverState.peek()?.mediaId != mediaId) return;
sync.sendArt(mediaId,
poster: poster == null ? null : base64Encode(poster),
banner: banner == null ? null : base64Encode(banner));
} catch (e) {
debugPrint('art for media $mediaId: $e');
}
}
/// A pasted link: a recognised Jellyfin item link goes up as a ref
/// (see [mediaForLink]) that this member relays for viewers without an
/// account (with the link's key, which stays here); anything else as the
@@ -993,11 +1045,11 @@ class SessionCoordinator {
final media = await mediaForLink(jellyfin, url);
if (media.jellyfin != null) {
_pendingOrigin = _JellyfinOrigin(url);
sync.sendSwap(media.copyWith(relay: const MediaRelay()));
_swap(media.copyWith(relay: const MediaRelay()));
return null;
}
if (relay == LinkRelay.direct) {
sync.sendSwap(media);
_swap(media);
return null;
}
// Relayed: it must be a single file the relay can range-read (not a
@@ -1015,9 +1067,9 @@ class SessionCoordinator {
}
if (relay == LinkRelay.me) {
_pendingOrigin = _LinkOrigin(url);
sync.sendSwap(HelloMedia(name: media.name, size: size, duration: 0, relay: const MediaRelay()));
_swap(HelloMedia(name: media.name, size: size, duration: 0, relay: const MediaRelay()));
} else {
sync.sendSwap(HelloMedia(
_swap(HelloMedia(
name: media.name, size: size, duration: 0, url: url, relay: const MediaRelay(fetch: true)));
}
return null;
@@ -1028,7 +1080,7 @@ class SessionCoordinator {
void swapJellyfin(JellyfinMedia media) {
markInput();
_pendingOrigin = const _JellyfinOrigin(null);
sync.sendSwap(mediaForPick(media).copyWith(relay: const MediaRelay()));
_swap(mediaForPick(media).copyWith(relay: const MediaRelay()));
}
/// A file on this computer. The host's app serves it itself; anyone
@@ -1040,13 +1092,13 @@ class SessionCoordinator {
if (host != null) {
final file = await host(path);
if (file == null) return 'file not found — check the path';
sync.sendSwap(HelloMedia(name: file.name, size: file.size, duration: 0));
_swap(HelloMedia(name: file.name, size: file.size, duration: 0));
return null;
}
final file = File(path);
if (!await file.exists()) return 'file not found — check the path';
_pendingOrigin = _FileOrigin(path);
sync.sendSwap(HelloMedia(
_swap(HelloMedia(
name: file.uri.pathSegments.last, size: await file.length(), duration: 0, relay: const MediaRelay()));
return null;
}
@@ -1541,6 +1593,7 @@ class SessionCoordinator {
await _stopJellyfin();
_stopRelaySource();
_pendingOrigin = null;
_pendingArt = null;
_activeOrigin = null;
_playerReady = false;
try {
@@ -1567,6 +1620,7 @@ class SessionCoordinator {
await _kickSub?.cancel();
await _errSub?.cancel();
await _statusSub?.cancel();
_aniList?.close();
await sync.dispose();
await playback.dispose();
}
+7 -2
View File
@@ -15,11 +15,16 @@ const fadeOutPart = 0.35;
/// Fade through (Material motion): the old view is gone after the first
/// 35%, then the new one fades and scales in; they never overlap. Key
/// [child] by what it shows so a change of key runs the transition.
Widget fadeThrough(Widget child, {Key? key}) => AnimatedSwitcher(
/// [expand] false: sized by the views (at [alignment]) instead of filling
/// the box, for unbounded places (a row, a pill).
Widget fadeThrough(Widget child,
{Key? key, bool expand = true, AlignmentGeometry alignment = AlignmentDirectional.topStart}) =>
AnimatedSwitcher(
key: key,
duration: fadeThroughTime,
layoutBuilder: (current, previous) => Stack(
fit: StackFit.expand,
fit: expand ? StackFit.expand : StackFit.loose,
alignment: alignment,
children: [...previous, ?current],
),
transitionBuilder: (c, anim) {
@@ -0,0 +1,171 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../session/now_playing.dart';
import '../state/session_state.dart';
import 'motion.dart';
/// Tests: art bytes by hash instead of the session's cache.
@visibleForTesting
Uint8List? Function(String hash)? debugArtBytes;
/// The one lookup for media art (cover, banner) by content hash: null
/// when there is none or it has not arrived yet (listeners of
/// [artChanges] hear when it lands).
Uint8List? artBytes(String hash) {
if (hash.isEmpty) return null;
final debug = debugArtBytes;
if (debug != null) return debug(hash);
return SessionState.instance.coordinator.art.bytesFor(hash);
}
/// Notifies when art arrives.
Listenable artChanges() => SessionState.instance.coordinator.art;
final _images = <String, MemoryImage>{};
/// [hash]'s art as an image, the same provider while the bytes are the
/// same (so a rebuild never decodes again).
ImageProvider? artImage(String hash) {
final bytes = artBytes(hash);
if (bytes == null) return null;
final hit = _images[hash];
if (hit != null && identical(hit.bytes, bytes)) return hit;
if (_images.length > 16) _images.clear();
return _images[hash] = MemoryImage(bytes);
}
/// [image] fading in (ease) once decoded; nothing before.
Widget fadeInImage(ImageProvider image, {BoxFit fit = BoxFit.cover}) => Image(
image: image,
fit: fit,
gaplessPlayback: true,
frameBuilder: (context, child, frame, sync) => sync
? child
: AnimatedOpacity(opacity: frame == null ? 0 : 1, duration: easeTime, curve: ease, child: child),
);
/// The portrait cover (2:3) of the current media, a quiet tile with a
/// film icon until (or unless) its art arrives.
class ArtCover extends StatelessWidget {
final String hash;
final double width;
const ArtCover({super.key, required this.hash, this.width = 40});
@override
Widget build(BuildContext context) {
final image = artImage(hash);
final r = BorderRadius.circular(width >= 80 ? 10 : 6);
return Container(
width: width,
height: width * 1.5,
decoration: BoxDecoration(
borderRadius: r,
boxShadow: const [BoxShadow(color: Color(0x66000000), blurRadius: 10, offset: Offset(0, 3))],
),
child: ClipRRect(
borderRadius: r,
child: Stack(
fit: StackFit.expand,
children: [
DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF2A2D35), Color(0xFF17191E)],
),
),
child: Icon(Icons.movie_outlined, color: Colors.white38, size: width * 0.45),
),
if (image != null) fadeInImage(image),
DecoratedBox(
decoration: BoxDecoration(
borderRadius: r,
border: Border.all(color: Colors.white12),
),
),
],
),
),
);
}
}
const _shadow = [Shadow(color: Color(0x99000000), blurRadius: 8)];
/// Title and episode line of [now] (with its cover unless [cover] is
/// off), ellipsized; cross-fades when the media changes.
class NowPlayingBlock extends StatelessWidget {
final NowPlaying? now;
final bool cover;
/// Smaller type (the mini player).
final bool compact;
const NowPlayingBlock({super.key, required this.now, this.cover = true, this.compact = false});
@override
Widget build(BuildContext context) {
final n = now;
final Widget child;
if (n == null) {
child = const SizedBox.shrink(key: ValueKey('now-playing-none'));
} else {
final episode = n.episode;
child = Row(
key: ValueKey('now-playing:${n.title}\n${episode ?? ''}'),
mainAxisSize: MainAxisSize.min,
children: [
if (cover) ...[ArtCover(hash: n.posterHash), const SizedBox(width: 12)],
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
n.title,
key: const ValueKey('now-playing-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
fontSize: compact ? 13 : 16,
shadows: _shadow),
),
if (episode != null && episode.isNotEmpty)
Text(
episode,
key: const ValueKey('now-playing-episode'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Colors.white70, fontSize: compact ? 11 : 13, shadows: _shadow),
),
],
),
),
],
);
}
return fadeThrough(child, expand: false, alignment: AlignmentDirectional.centerStart);
}
}
/// [NowPlayingBlock] of the session's current media.
class NowPlayingTitle extends StatelessWidget {
final bool cover;
final bool compact;
const NowPlayingTitle({super.key, this.cover = true, this.compact = false});
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: artChanges(),
builder: (context, _) => SignalBuilder(
builder: (context) => NowPlayingBlock(
now: SessionState.instance.coordinator.nowPlaying.value,
cover: cover,
compact: compact,
),
),
);
}
@@ -0,0 +1,366 @@
import 'dart:async';
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import '../session/health.dart';
import '../session/session_coordinator.dart';
import '../state/session_state.dart';
import 'avatar.dart';
import 'motion.dart';
/// What the player's status pill says, most pressing first.
enum StatusKind { kicked, reconnecting, problem, connecting, finished, loading, you, waiting }
/// The pill's button: stop reconnecting, retry, or mark yourself ready
/// (the whole pill).
enum StatusAction { cancel, retry, ready }
/// One status line: [text], a muted [detail], a coloured [tone] for
/// problems (warn, error), the members it is about ([who]; [self] for you)
/// and a loading [progress] (0..1).
class StatusView {
final StatusKind kind;
final String text;
final String? detail;
final HealthSeverity? tone;
final List<MemberView> who;
final bool self;
final double? progress;
final StatusAction? action;
/// The mini player's shorter line.
final String short;
const StatusView(
this.kind,
this.text, {
this.detail,
this.tone,
this.who = const [],
this.self = false,
this.progress,
this.action,
String? short,
}) : short = short ?? text;
}
/// "Bob", "Bob and Mika", "Bob, Mika and Cleo", "Bob, Mika and 3 others".
String nameList(List<String> names) {
if (names.length <= 1) return names.join();
if (names.length <= 3) return '${names.sublist(0, names.length - 1).join(', ')} and ${names.last}';
return '${names[0]}, ${names[1]} and ${names.length - 2} others';
}
String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
/// The player's status from the session's signals; null when there is
/// nothing to say (playing, connected, everyone ready). [slowConnect]:
/// connecting has taken over 2 s.
StatusView? playerStatusFor({
required HealthStatus health,
bool reconnecting = false,
String? kickReason,
StateBroadcast? state,
MemberView? self,
bool slowConnect = false,
}) {
if (kickReason != null) {
return StatusView(StatusKind.kicked,
kickReason.isEmpty ? 'You were removed from the server' : 'You were removed: $kickReason',
tone: HealthSeverity.error, short: 'Removed from the server');
}
if (reconnecting) {
return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
tone: HealthSeverity.warn, action: StatusAction.cancel);
}
if (health.severity == HealthSeverity.warn || health.severity == HealthSeverity.error) {
return StatusView(StatusKind.problem, _sentence(health.message),
tone: health.severity, action: StatusAction.retry);
}
if (slowConnect && health.severity == HealthSeverity.info && health.message.startsWith('connecting')) {
return const StatusView(StatusKind.connecting, 'Connecting…');
}
final r = state;
if (r == null || r.media == null || r.transport == 'playing') return null;
if (r.transport == 'finished') {
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
}
// As the server holds playback: someone in the ready set short of the
// minimum buffer.
for (final m in r.members) {
if (m.inSet && m.buffered < RelayConstants.minBufferSecs) {
final you = self != null && m.id == self.id;
return StatusView(StatusKind.loading, you ? 'Loading the video…' : '${m.name} is loading…',
who: [m], self: you, progress: (m.buffered / RelayConstants.minBufferSecs).clamp(0.0, 1.0));
}
}
if (self != null && !self.ready) {
return StatusView(StatusKind.you, "Press Space when you're ready",
who: [self], self: true, action: StatusAction.ready, short: 'Press Space when ready');
}
final active = [for (final m in r.members) if (!m.stale) m];
final others = [
for (final m in active)
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
];
if (others.isEmpty) return null;
final ready = active.where((m) => m.ready).length;
return StatusView(StatusKind.waiting, 'Waiting for ${nameList([for (final m in others) m.name])}',
who: others, detail: '$ready/${active.length} ready');
}
/// [playerStatusFor] from the session (tracked: call inside a builder).
StatusView? playerStatusNow({bool slowConnect = false}) {
final c = SessionState.instance.coordinator;
return playerStatusFor(
health: c.health.value,
reconnecting: c.reconnecting.value,
kickReason: c.kickReason.value,
state: c.serverState.value,
self: c.selfMember.value,
slowConnect: slowConnect,
);
}
/// The player's one status pill (bottom centre): frosted dark glass with
/// one short line, the members it is about and at most one button. Fades
/// in and out; a different kind of status fades through, the pill resizing
/// around it.
class PlayerStatus extends StatefulWidget {
const PlayerStatus({super.key});
static const connectGrace = Duration(seconds: 2);
@override
State<PlayerStatus> createState() => _PlayerStatusState();
}
class _PlayerStatusState extends State<PlayerStatus> {
Timer? _connectTimer;
bool _slow = false;
StatusView? _last;
@override
void dispose() {
_connectTimer?.cancel();
super.dispose();
}
// "Connecting…" only once it takes a while.
void _trackConnecting(HealthStatus h) {
final connecting = h.severity == HealthSeverity.info && h.message.startsWith('connecting');
if (!connecting) {
_connectTimer?.cancel();
_connectTimer = null;
_slow = false;
} else if (_connectTimer == null && !_slow) {
_connectTimer = Timer(PlayerStatus.connectGrace, () {
if (mounted) setState(() => _slow = true);
});
}
}
void _act(SessionCoordinator coord, StatusAction a) {
switch (a) {
case StatusAction.cancel:
coord.cancelReconnect();
case StatusAction.retry:
coord.retry();
case StatusAction.ready:
coord.toggleReady();
}
}
@override
Widget build(BuildContext context) {
return PartyWatch(builder: (context) {
final coord = SessionState.instance.coordinator;
_trackConnecting(coord.health.value);
final view = playerStatusNow(slowConnect: _slow);
if (view != null) _last = view;
final shown = _last;
final ready = view?.action == StatusAction.ready;
return AnimatedOpacity(
duration: easeTime,
curve: ease,
opacity: view == null ? 0 : 1,
child: IgnorePointer(
ignoring: view == null,
child: shown == null
? const SizedBox.shrink()
: MouseRegion(
cursor: ready ? SystemMouseCursors.click : MouseCursor.defer,
child: GestureDetector(
key: const ValueKey('player-status'),
onTap: ready ? coord.toggleReady : null,
child: _Glass(
child: AnimatedSize(
duration: easeTime,
curve: ease,
child: fadeThrough(
_StatusLine(
key: ValueKey(shown.kind),
view: shown,
coord: coord,
onAction: (a) => _act(coord, a),
),
expand: false,
alignment: Alignment.center,
),
),
),
),
),
),
);
});
}
}
class _Glass extends StatelessWidget {
final Widget child;
const _Glass({required this.child});
@override
Widget build(BuildContext context) {
final r = BorderRadius.circular(999);
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: ClipRRect(
borderRadius: r,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: r,
border: Border.all(color: Colors.white12, width: 0.5),
),
child: child,
),
),
),
);
}
}
class _StatusLine extends StatelessWidget {
final StatusView view;
final SessionCoordinator coord;
final ValueChanged<StatusAction> onAction;
const _StatusLine({super.key, required this.view, required this.coord, required this.onAction});
static const _avatar = 24.0;
Widget? _lead() {
final v = view;
switch (v.kind) {
case StatusKind.kicked:
case StatusKind.problem:
final error = v.tone == HealthSeverity.error;
return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded,
size: 18, color: error ? Colors.redAccent.shade100 : Colors.amber);
case StatusKind.reconnecting:
return const _Spinner(color: Colors.amber);
case StatusKind.connecting:
return const _Spinner(color: Colors.white70);
case StatusKind.finished:
return null;
case StatusKind.loading:
case StatusKind.you:
case StatusKind.waiting:
final r = coord.serverState.value;
final infos = [
for (final m in v.who)
v.self ? selfAvatarInfo(ready: m.ready, host: m.host) : memberAvatarInfo(m, r, coord),
];
if (infos.isEmpty) return null;
return infos.length == 1
? StatusAvatar(info: infos.first, size: _avatar, badges: false)
: AvatarStack(members: infos, shown: 4, size: _avatar);
}
}
@override
Widget build(BuildContext context) {
final v = view;
final lead = _lead();
final action = v.action == StatusAction.ready ? null : v.action;
return Padding(
padding: EdgeInsets.fromLTRB(lead == null ? 16 : 6, 6, action == null ? 16 : 6, 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (lead != null) ...[
SizedBox(height: _avatar, child: Center(child: lead)),
const SizedBox(width: 8),
],
Flexible(
child: Text(
v.text,
key: const ValueKey('player-status-text'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600),
),
),
if (v.progress != null) ...[
const SizedBox(width: 8),
SizedBox.square(
dimension: 14,
child: CircularProgressIndicator(
value: v.progress,
strokeWidth: 2,
color: Colors.white,
backgroundColor: Colors.white24,
),
),
],
if (v.detail != null) ...[
const SizedBox(width: 8),
Text(v.detail!, style: const TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.w500)),
],
if (action != null) ...[
const SizedBox(width: 10),
_PillButton(
label: action == StatusAction.cancel ? 'Cancel' : 'Retry',
onPressed: () => onAction(action),
),
],
],
),
);
}
}
class _Spinner extends StatelessWidget {
final Color color;
const _Spinner({required this.color});
@override
Widget build(BuildContext context) =>
SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2, color: color));
}
class _PillButton extends StatelessWidget {
final String label;
final VoidCallback onPressed;
const _PillButton({required this.label, required this.onPressed});
@override
Widget build(BuildContext context) => TextButton(
key: ValueKey('player-status-$label'),
onPressed: onPressed,
style: TextButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: Colors.white12,
shape: const StadiumBorder(),
minimumSize: const Size(0, 26),
padding: const EdgeInsets.symmetric(horizontal: 12),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
textStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
child: Text(label),
);
}
+22 -182
View File
@@ -13,7 +13,6 @@ import '../controls/uosc/timeline.dart';
import '../playback/media_marks.dart' show SkipOffer, formatClock;
import '../screens/session/jellyfin_widgets.dart';
import '../screens/session/player_controls.dart';
import '../session/health.dart';
import '../session/session_coordinator.dart';
import '../shaders/shader_menu.dart';
import '../shaders/shader_presets.dart';
@@ -24,6 +23,9 @@ import '../widgets/text_focus.dart';
import '../widgets/window_buttons.dart';
import 'avatar.dart';
import 'motion.dart';
import 'now_playing_title.dart';
import 'player_status.dart';
import 'start_banner.dart';
/// Where the session's video is shown: nowhere, the floating mini player,
/// or filling the body (and, with fullscreen, the window).
@@ -36,8 +38,8 @@ typedef VideoViewBuilder = Widget Function(BuildContext context, VideoControlsBu
/// The session's player: the one `Video` of the current player and
/// everything over it. Full: our bars on media_kit's controls or the uosc
/// set (Settings), health / waiting / Jellyfin overlays and the player
/// keys. Mini: hover controls and a progress line. The shell keeps this
/// set (Settings), the start card, the status pill, Jellyfin overlays and
/// the player keys. Mini: hover controls and a progress line. The shell keeps this
/// widget mounted at one place in the tree while the mode changes, so the
/// `Video` is never mounted twice nor rebuilt for a move.
class SessionPlayer extends StatefulWidget {
@@ -277,8 +279,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
child: Stack(fit: StackFit.expand, children: [video, _frameProbe.anchor()]),
),
if (_full) ...[
_healthOverlay(),
_waitingPill(bottom: 124),
_status(),
_progressLine(),
_jellyfinOverlay(),
if (widget.fullscreen) _pausedParty(),
@@ -306,6 +307,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
}
static const _barHeight = 56.0;
/// The standard top bar: room for the cover (2:3, 60 px tall).
static const _topBarHeight = 72.0;
static const _controlsHide = Duration(seconds: 3);
/// Fullscreen hides the strip and its window buttons: the top bar
@@ -346,14 +349,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
),
);
String get _title {
final ref = _coord.currentRef.value;
if (ref != null && ref.title.isNotEmpty) {
return [ref.title, ?ref.subtitle].join(' · ');
}
return (_coord.serverState.value?.media?['name'] as String?) ?? '';
}
MaterialDesktopVideoControlsThemeData _controlsTheme() {
final coord = _coord;
return MaterialDesktopVideoControlsThemeData(
@@ -375,19 +370,10 @@ class _SessionPlayerState extends State<SessionPlayer> {
child: Align(
alignment: Alignment.topCenter,
child: SizedBox(
height: _barHeight,
height: _topBarHeight,
child: Row(
children: [
Expanded(
child: SignalBuilder(
builder: (context) => Text(
_title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 16),
),
),
),
const Expanded(child: NowPlayingTitle()),
MaterialDesktopCustomButton(
icon: const Icon(Icons.video_library_outlined),
onPressed: widget.onChangeVideo,
@@ -510,7 +496,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
}
Widget _sessionControls(VideoState state) {
// The start card sits under the controls (they stay usable on it).
// The "Player design" setting swaps them live.
return Stack(fit: StackFit.expand, children: [const StartBanner(), _controlsSwitch(state)]);
}
Widget _controlsSwitch(VideoState state) {
return SessionControlsSwitch(
material: (_) => ControlsHoverProbe(
shown: _standardShown,
@@ -518,7 +509,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
child: DoubleClickListener(
onDoubleClick: widget.onToggleFullscreen,
// Clicks on the bars are button clicks, not the video.
skipTop: _barHeight,
skipTop: _topBarHeight,
skipBottom: _barHeight + ChapterSeekBar.height,
child: MaterialDesktopVideoControls(state),
),
@@ -546,6 +537,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
});
return UoscControls(
onDoubleTap: widget.onToggleFullscreen,
topLeft: const NowPlayingTitle(),
top: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -655,80 +647,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
);
}
Widget _healthOverlay() {
final coord = _coord;
return Positioned(
top: _barHeight + 8,
left: 12,
right: 12,
child: Align(
alignment: Alignment.topLeft,
child: SignalBuilder(
builder: (context) {
final h = coord.health.value;
final starve = coord.starving.value;
final retrying = coord.reconnecting.value;
final kicked = coord.kickReason.value;
final unwell = h.severity == HealthSeverity.warn ||
h.severity == HealthSeverity.error ||
retrying ||
starve != null;
if (!unwell) return const SizedBox.shrink();
final color = switch (h.severity) {
HealthSeverity.ok => Colors.green,
HealthSeverity.info => Colors.grey,
HealthSeverity.warn => Colors.orange,
HealthSeverity.error => Colors.red,
};
final IconData icon;
if (retrying) {
icon = Icons.sync;
} else if (h.severity == HealthSeverity.error) {
icon = Icons.error_outline;
} else if (h.severity == HealthSeverity.warn) {
icon = Icons.warning_amber_outlined;
} else {
icon = Icons.hourglass_empty;
}
final text = [h.message, ?starve, if (retrying) 'reconnecting…'].join(' · ');
final canRetry = !retrying &&
kicked == null &&
(h.severity == HealthSeverity.error || h.severity == HealthSeverity.warn);
final button = TextButton.styleFrom(
foregroundColor: Colors.white, tapTargetSize: MaterialTapTargetSize.shrinkWrap);
return Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.65), borderRadius: BorderRadius.circular(999)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: color, size: 16),
const SizedBox(width: 8),
Flexible(child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 12))),
if (retrying)
TextButton(
style: button,
onPressed: coord.cancelReconnect,
child: const Text('Cancel', style: TextStyle(fontSize: 12)),
),
if (canRetry)
TextButton(
style: button,
onPressed: coord.retry,
child: const Text('Retry', style: TextStyle(fontSize: 12)),
),
],
),
);
},
),
),
);
}
Widget _waitingPill({required double bottom}) =>
Positioned(left: 0, right: 0, bottom: bottom, child: const Center(child: WaitingPill()));
/// The one status pill, bottom centre above the controls.
Widget _status() =>
const Positioned(left: 16, right: 16, bottom: 124, child: Center(child: PlayerStatus()));
/// Jellyfin: the blocked card, the end-of-episode card (countdown) and
/// the "Next episode" pill; for any media the skip pill (Skip OP / ED /
@@ -807,7 +728,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
Widget _miniOverlay() {
final coord = _coord;
final playback = coord.playback;
const white = TextStyle(color: Colors.white, fontWeight: FontWeight.w600);
return MouseRegion(
onEnter: (_) => setState(() => _miniHover = true),
onExit: (_) => setState(() => _miniHover = false),
@@ -832,12 +752,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
Row(
children: [
const SizedBox(width: 10),
Expanded(
child: SignalBuilder(
builder: (context) =>
Text(_title, maxLines: 1, overflow: TextOverflow.ellipsis, style: white),
),
),
const Expanded(child: NowPlayingTitle(cover: false, compact: true)),
IconButton(
focusNode: _noFocus,
onPressed: widget.onExpand,
@@ -871,7 +786,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
style: const TextStyle(color: Colors.white70, fontSize: 12)),
const Spacer(),
Flexible(
child: Text(waitingText(compact: true),
child: Text(playerStatusNow()?.short ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.amber, fontSize: 12)),
@@ -904,81 +819,6 @@ const _shaderKeys = [
LogicalKeyboardKey.digit6,
];
/// Who the server waits for: "Waiting for you · press Space when ready",
/// "Waiting for Alex, Mika", or '' while playing or with nothing up.
String waitingText({bool compact = false}) {
final coord = SessionState.instance.coordinator;
final r = coord.serverState.value;
if (r == null || r.media == null || r.transport != 'held') return '';
final self = coord.selfMember.value;
if (self != null && !self.ready) {
return compact ? 'waiting for you' : 'Waiting for you · press Space when ready';
}
final waiting = [for (final m in r.members) if (!m.ready && !m.stale) m.name];
if (waiting.isEmpty) return compact ? '' : 'Paused';
return '${compact ? 'waiting' : 'Waiting'} for ${waiting.join(', ')}';
}
/// While held: who everyone is waiting for, with their avatars. When it
/// is you, a click marks you ready.
class WaitingPill extends StatelessWidget {
const WaitingPill({super.key});
@override
Widget build(BuildContext context) {
return PartyWatch(builder: (context) {
final coord = SessionState.instance.coordinator;
final text = waitingText();
final r = coord.serverState.value;
final self = coord.selfMember.value;
final meWaiting = self != null && !self.ready;
final who = meWaiting
? [selfAvatarInfo()]
: [
for (final m in r?.members ?? const <MemberView>[])
if (!m.ready && !m.stale) memberAvatarInfo(m, r, coord),
];
return AnimatedOpacity(
duration: easeTime,
curve: ease,
opacity: text.isEmpty ? 0 : 1,
child: IgnorePointer(
ignoring: text.isEmpty || !meWaiting,
child: GestureDetector(
key: const ValueKey('waiting-pill'),
onTap: coord.toggleReady,
child: MouseRegion(
cursor: meWaiting ? SystemMouseCursors.click : MouseCursor.defer,
child: Container(
padding: const EdgeInsets.fromLTRB(8, 6, 16, 6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(30),
border: Border.all(color: Colors.white12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final m in who.take(4))
Padding(padding: const EdgeInsets.only(right: 4), child: StatusAvatar(info: m, size: 28)),
const SizedBox(width: 6),
Flexible(
child: Text(text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600)),
),
],
),
),
),
),
),
);
});
}
}
/// Mini player progress: a 2 px line, or (hovered) a 4 px bar that seeks.
class _MiniSeekBar extends StatelessWidget {
final bool thin;
@@ -0,0 +1,176 @@
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../session/now_playing.dart';
import '../state/session_state.dart';
import 'motion.dart';
import 'now_playing_title.dart';
/// Space kept free at the bottom of the start card for the controls and
/// the status pill.
const startCardBottom = 176.0;
/// The start card of [now]: its banner (cover-fit, darkened), else a soft
/// blur of the cover, else dark; the cover, a big title and the episode
/// line bottom left, above the controls.
class StartCard extends StatelessWidget {
final NowPlaying now;
const StartCard({super.key, required this.now});
@override
Widget build(BuildContext context) {
final banner = artImage(now.bannerHash);
final cover = banner == null ? artImage(now.posterHash) : null;
final episode = now.episode;
return LayoutBuilder(builder: (context, box) {
final h = box.maxHeight;
final coverWidth = (h * 0.26).clamp(84.0, 240.0) / 1.5;
final titleSize = (h * 0.05).clamp(22.0, 44.0);
final side = (box.maxWidth * 0.05).clamp(20.0, 72.0);
return Stack(
fit: StackFit.expand,
children: [
const ColoredBox(color: Color(0xFF0C0D11)),
if (banner != null)
fadeInImage(banner)
else if (cover != null)
Opacity(
opacity: 0.5,
child: ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 48, sigmaY: 48, tileMode: TileMode.decal),
child: Transform.scale(scale: 1.3, child: fadeInImage(cover)),
),
)
else
const DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment(-0.7, 0.5),
radius: 1.2,
colors: [Color(0xFF23262E), Color(0xFF0C0D11)],
),
),
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0, 0.35, 1],
colors: [Color(0x55000000), Color(0x22000000), Color(0xE6000000)],
),
),
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
stops: [0, 0.65],
colors: [Color(0x99000000), Color(0x00000000)],
),
),
),
Positioned(
left: side,
right: side,
bottom: startCardBottom,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
ArtCover(hash: now.posterHash, width: coverWidth),
const SizedBox(width: 24),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
now.title,
key: const ValueKey('start-card-title'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: titleSize,
fontWeight: FontWeight.w800,
height: 1.1,
shadows: const [Shadow(color: Color(0x99000000), blurRadius: 16)],
),
),
if (episode != null && episode.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
episode,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Colors.white70, fontSize: titleSize * 0.42),
),
],
],
),
),
],
),
),
],
);
});
}
}
/// [StartCard] over the picture until playback starts on this media
/// (the server plays, or its position passed 1 s); then it fades out and
/// stays gone for that media, whatever happens later. Never takes input.
class StartBanner extends StatefulWidget {
const StartBanner({super.key});
@override
State<StartBanner> createState() => _StartBannerState();
}
class _StartBannerState extends State<StartBanner> {
int? _mediaId;
bool _started = false;
// Faded out: the card leaves the tree (and its images with it).
bool _gone = true;
NowPlaying? _last;
@override
Widget build(BuildContext context) {
final coord = SessionState.instance.coordinator;
return IgnorePointer(
child: ListenableBuilder(
listenable: artChanges(),
builder: (context, _) => SignalBuilder(builder: (context) {
final r = coord.serverState.value;
final now = coord.nowPlaying.value;
if (r != null && r.mediaId != _mediaId) {
_mediaId = r.mediaId;
_started = false;
}
if (r != null && (r.transport == 'playing' || r.position >= 1)) _started = true;
final show = now != null && r?.media != null && !_started;
if (show) {
_last = now;
_gone = false;
}
final last = _last;
if (last == null || _gone) return const SizedBox.shrink();
return AnimatedOpacity(
key: const ValueKey('start-banner'),
opacity: show ? 1 : 0,
duration: easeTime,
curve: ease,
onEnd: () {
if (!show && mounted) setState(() => _gone = true);
},
child: StartCard(now: last),
);
}),
),
);
}
}
+37 -12
View File
@@ -3,15 +3,38 @@ import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart';
import 'sync_client.dart';
/// Member pictures by content hash (`state.members[].avatarHash`), fetched
/// once from `/a/<sid>/<token>/<hash>` on the host the client dialled and
/// kept in memory. A hash names fixed bytes, so entries never go stale;
/// listeners are notified when one arrives.
class AvatarCache extends ChangeNotifier {
/// Member pictures by content hash (`state.members[].avatarHash`), from `/a/`.
class AvatarCache extends PictureCache {
AvatarCache(super.sync, {super.fetch})
: super(route: 'a', keep: 32, live: (s) => [for (final m in s.members) m.avatarHash]);
}
/// The current media's art by content hash (`state.media.art`), from `/i/`.
class ArtCache extends PictureCache {
ArtCache(super.sync, {super.fetch}) : super(route: 'i', keep: 8, live: artHashesOf);
/// Poster and banner hashes of [s]'s media.
static List<String> artHashesOf(StateBroadcast s) {
final art = s.media?['art'];
if (art is! Map) return const [];
return [for (final v in art.values) if (v is String) v];
}
}
/// Pictures by content hash, fetched once from `/<route>/<sid>/<token>/<hash>`
/// on the host the client dialled and kept in memory. A hash names fixed
/// bytes, so entries never go stale; listeners are notified when one
/// arrives. Hashes in each state ([live]) are fetched ahead of use.
class PictureCache extends ChangeNotifier {
final SyncClient sync;
/// First path segment of the picture route: 'a' (avatars), 'i' (art).
final String route;
/// Above this many pictures, ones not in the latest state go.
final int keep;
/// Fetches one picture; null on any failure. Injectable for tests.
final Future<Uint8List?> Function(Uri url) _fetch;
final Map<String, Uint8List> _bytes = {};
@@ -21,15 +44,17 @@ class AvatarCache extends ChangeNotifier {
bool _disposed = false;
static const _retryAfter = Duration(seconds: 10);
/// Above this many pictures, ones nobody in the latest state uses go.
static const _keep = 32;
AvatarCache(this.sync, {Future<Uint8List?> Function(Uri url)? fetch})
PictureCache(this.sync,
{required this.route,
required this.keep,
required Iterable<String> Function(StateBroadcast state) live,
Future<Uint8List?> Function(Uri url)? fetch})
: _fetch = fetch ?? _httpGet {
_sub = sync.states.listen((s) {
final live = {for (final m in s.members) if (m.avatarHash.isNotEmpty) m.avatarHash};
if (_bytes.length > _keep) _bytes.removeWhere((h, _) => !live.contains(h));
live.forEach(_want);
final hashes = {for (final h in live(s)) if (h.isNotEmpty) h};
if (_bytes.length > keep) _bytes.removeWhere((h, _) => !hashes.contains(h));
hashes.forEach(_want);
});
}
@@ -53,7 +78,7 @@ class AvatarCache extends ChangeNotifier {
scheme: ws.scheme == 'wss' ? 'https' : 'http',
host: ws.host,
port: ws.port,
pathSegments: ['a', 'default', token, hash]);
pathSegments: [route, 'default', token, hash]);
_inFlight.add(hash);
unawaited(_fetch(url).then((bytes) {
_inFlight.remove(hash);
@@ -250,6 +250,11 @@ class SyncClient {
_send(ProfileIntent(name: name, avatar: avatar).toJson());
}
/// Art for media [mediaId] (base64 picture bytes; null = none).
void sendArt(int mediaId, {String? poster, String? banner}) {
_send(ArtMessage(mediaId: mediaId, poster: poster, banner: banner).toJson());
}
void sendKick(String target, {String reason = ''}) {
_send(KickRequest(target: target, reason: reason).toJson());
}
+31
View File
@@ -56,4 +56,35 @@ void main() {
await server.stop();
}
});
test('media art arrives by hash over /i/; a swap clears it', () async {
final server = SyncServer(const AppConfig(port: 0, secret: 's', name: 'art'));
await server.start();
final client = SyncClient();
final cache = ArtCache(client);
final poster = Uint8List.fromList([0xFF, 0xD8, 0xFF, ...List<int>.generate(197, (i) => i % 256)]);
try {
await client.connect(
wsUri: Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'),
secret: 's',
name: 'alice',
);
client.sendSwap(const HelloMedia(name: 'Show - 01.mkv', size: 0, duration: 0, url: 'http://example.com/a.mkv'));
await _poll(() => client.lastState?.media?['swapBy'] == client.memberId, 'swap');
client.sendArt(client.lastState!.mediaId, poster: base64Encode(poster));
await _poll(() => ArtCache.artHashesOf(client.lastState!).isNotEmpty, 'art in state');
final hash = ArtCache.artHashesOf(client.lastState!).single;
expect(hash, avatarHashOf(poster));
await _poll(() => cache.bytesFor(hash) != null, 'art bytes');
expect(cache.bytesFor(hash), poster);
client.sendSwap(const HelloMedia(name: 'Other.mkv', size: 0, duration: 0, url: 'http://example.com/b.mkv'));
await _poll(() => client.lastState?.media?['name'] == 'Other.mkv', 'second swap');
expect(ArtCache.artHashesOf(client.lastState!), isEmpty);
} finally {
cache.dispose();
await client.dispose();
await server.stop();
}
});
}
@@ -0,0 +1,45 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/session/now_playing.dart';
import 'package:watch_together/src/session/now_playing_info.dart';
void main() {
group('jellyfinTitle', () {
test('episodes: series on top, "S1 E2 · Name" below', () {
expect(jellyfinTitle('The Pilot', 'Show · S1E2'), ('Show', 'S1 E2 · The Pilot'));
expect(jellyfinTitle('The Pilot', 'Show - S01E02'), ('Show', 'S1 E2 · The Pilot'));
expect(jellyfinTitle('Ep', 'Re:Zero - Starting Life · S2E10'), ('Re:Zero - Starting Life', 'S2 E10 · Ep'));
});
test('movies and partial subtitles', () {
expect(jellyfinTitle('Movie', '2019'), ('Movie', null));
expect(jellyfinTitle('Movie', null), ('Movie', null));
expect(jellyfinTitle('Pilot', 'S1E1'), ('Pilot', 'S1 E1'));
expect(jellyfinTitle('Special', 'Show'), ('Show', 'Special'));
});
});
group('fileTitle', () {
test('release names', () {
expect(fileTitle('[SubsPlease] Frieren - 05 (1080p) [ABCD1234].mkv'), ('Frieren', 'E5'));
expect(fileTitle('Show.Name.S01E03.1080p.WEB-DL.x264-GRP.mkv'), ('Show Name', 'S1 E3'));
expect(fileTitle('[Grp] Show - NCOP1 [1080p].mkv'), ('Show', 'NCOP1'));
expect(fileTitle('Some Movie (2019).mkv'), ('Some Movie', null));
});
test('nothing sensible: the raw name minus extension', () {
expect(fileTitle('[1080p].mkv').$1, '[1080p]');
});
});
test('nowPlayingOf: media map with ref and art', () {
expect(nowPlayingOf(null), isNull);
expect(
nowPlayingOf({
'name': 'The Pilot',
'jellyfin': {'serverId': 's', 'itemId': 'i', 'title': 'The Pilot', 'subtitle': 'Show · S1E2'},
'art': {'poster': 'aaaa', 'banner': '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'));
});
}
+283
View File
@@ -0,0 +1,283 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/controls/uosc/buttons.dart';
import 'package:watch_together/src/controls/uosc/controls.dart';
import 'package:watch_together/src/controls/uosc/timeline.dart';
import 'package:watch_together/src/session/health.dart';
import 'package:watch_together/src/session/now_playing.dart';
import 'package:watch_together/src/shell/now_playing_title.dart';
import 'package:watch_together/src/shell/player_status.dart';
import 'package:watch_together/src/shell/session_player.dart';
import 'package:watch_together/src/shell/start_banner.dart';
import 'package:watch_together/src/state/session_state.dart';
// The player's title block, start card and status pill (never a media_kit
// `Video`).
/// A 1x1 transparent PNG.
final png = Uint8List.fromList(const [
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, //
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4,
0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE,
0x42, 0x60, 0x82,
]);
MemberView mv(String id, String name, {bool ready = false, bool? inSet, double buffered = 30, bool stale = false}) =>
MemberView(id: id, name: name, ready: ready, inSet: inSet ?? ready, buffered: buffered, stale: stale);
StateBroadcast held(List<MemberView> members, {String transport = 'held', double position = 0, int mediaId = 1}) =>
StateBroadcast(
eventId: 1,
transport: transport,
position: position,
serverTime: DateTime.now().toUtc(),
mediaId: mediaId,
media: {'name': 'Movie.mkv', 'size': 1, 'duration': 0},
members: members,
);
const ok = HealthStatus(HealthSeverity.ok, 'connected');
void main() {
final coord = SessionState.instance.coordinator;
tearDown(() {
debugArtBytes = null;
coord.serverState.value = null;
coord.health.value = const HealthStatus(HealthSeverity.info, 'idle');
coord.playback.alive.value = false;
});
group('status', () {
final me = mv('me', 'Me', ready: true);
test('problems first: removed, reconnecting, health', () {
var v = playerStatusFor(
health: const HealthStatus(HealthSeverity.error, 'kicked: spam'),
kickReason: 'spam',
reconnecting: true,
state: held([me]));
expect(v!.kind, StatusKind.kicked);
expect(v.text, 'You were removed: spam');
expect(v.tone, HealthSeverity.error);
expect(v.action, isNull);
v = playerStatusFor(health: const HealthStatus(HealthSeverity.warn, 'connection lost — retrying…'),
reconnecting: true);
expect(v!.text, 'Reconnecting…');
expect(v.action, StatusAction.cancel);
v = playerStatusFor(health: const HealthStatus(HealthSeverity.warn, 'waiting for Bob to pass the video on'),
state: held([me]), self: me);
expect(v!.kind, StatusKind.problem);
expect(v.text, 'Waiting for Bob to pass the video on');
expect(v.tone, HealthSeverity.warn);
expect(v.action, StatusAction.retry);
});
test('info never shows; connecting only once slow', () {
expect(playerStatusFor(health: const HealthStatus(HealthSeverity.info, 'connected — waiting for host media…')),
isNull);
const connecting = HealthStatus(HealthSeverity.info, 'connecting…');
expect(playerStatusFor(health: connecting), isNull);
expect(playerStatusFor(health: connecting, slowConnect: true)!.text, 'Connecting…');
expect(playerStatusFor(health: ok, state: held([me], transport: 'playing'), self: me), isNull);
expect(playerStatusFor(health: ok, state: held([me]), self: me), isNull, reason: 'everyone ready');
});
test('finished, then whoever is loading', () {
expect(playerStatusFor(health: ok, state: held([me], transport: 'finished'), self: me)!.text,
'Finished · seek back or pick the next video');
final slow = mv('b', 'Bob', ready: true, buffered: RelayConstants.minBufferSecs / 2);
final v = playerStatusFor(health: ok, state: held([me, slow, mv('c', 'Cleo')]), self: me)!;
expect(v.kind, StatusKind.loading);
expect(v.text, 'Bob is loading…');
expect(v.who.single.id, 'b');
expect(v.progress, closeTo(0.5, 1e-9));
final mine = mv('me', 'Me', ready: true, buffered: 0);
expect(playerStatusFor(health: ok, state: held([mine]), self: mine)!.text, 'Loading the video…');
});
test('you, then the others with a ready count', () {
final notMe = mv('me', 'Me');
final v = playerStatusFor(health: ok, state: held([notMe, mv('b', 'Bob')]), self: notMe)!;
expect(v.kind, StatusKind.you);
expect(v.text, "Press Space when you're ready");
expect(v.action, StatusAction.ready);
expect(v.self, isTrue);
final w = playerStatusFor(
health: ok,
state: held([me, mv('b', 'Bob'), mv('m', 'Mika'), mv('z', 'Zed', stale: true)]),
self: me)!;
expect(w.kind, StatusKind.waiting);
expect(w.text, 'Waiting for Bob and Mika');
expect(w.detail, '1/3 ready');
expect(w.who.map((m) => m.name), ['Bob', 'Mika']);
});
test('names read as a sentence', () {
expect(nameList(['Bob']), 'Bob');
expect(nameList(['Bob', 'Mika']), 'Bob and Mika');
expect(nameList(['Bob', 'Mika', 'Cleo']), 'Bob, Mika and Cleo');
expect(nameList(['Bob', 'Mika', 'Cleo', 'Dan', 'Eve']), 'Bob, Mika and 3 others');
});
});
group('title block', () {
Widget host(Widget child, {double width = 400}) => MaterialApp(
home: Scaffold(backgroundColor: Colors.black, body: Center(child: SizedBox(width: width, child: child))),
);
const now = NowPlaying(title: 'Frieren', episode: 'S1 E4 · The Land Where Souls Rest', posterHash: 'p');
testWidgets('title, episode and a placeholder cover without art', (tester) async {
await tester.pumpWidget(host(const NowPlayingBlock(now: now)));
expect(find.text('Frieren'), findsOneWidget);
expect(find.text('S1 E4 · The Land Where Souls Rest'), findsOneWidget);
expect(find.byIcon(Icons.movie_outlined), findsOneWidget);
expect(find.byType(Image), findsNothing);
final cover = tester.getSize(find.byType(ArtCover));
expect(cover, const Size(40, 60));
});
testWidgets('the cover image once its bytes are here', (tester) async {
debugArtBytes = (hash) => hash == 'p' ? png : null;
await tester.pumpWidget(host(const NowPlayingBlock(now: now)));
final image = tester.widget<Image>(find.byType(Image));
expect((image.image as MemoryImage).bytes, png);
expect(image.gaplessPlayback, isTrue);
});
testWidgets('no cover, no episode: just the title; long titles ellipsize', (tester) async {
await tester.pumpWidget(host(
NowPlayingBlock(now: NowPlaying(title: 'A very long title ' * 10), cover: false, compact: true),
width: 200));
expect(find.byType(ArtCover), findsNothing);
expect(find.byKey(const ValueKey('now-playing-episode')), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('a new media fades through', (tester) async {
await tester.pumpWidget(host(const NowPlayingBlock(now: now)));
await tester.pumpWidget(host(const NowPlayingBlock(now: NowPlaying(title: 'Dune'))));
await tester.pump(const Duration(milliseconds: 60));
expect(find.text('Frieren'), findsOneWidget, reason: 'still fading out');
await tester.pumpAndSettle();
expect(find.text('Frieren'), findsNothing);
expect(find.text('Dune'), findsOneWidget);
});
});
testWidgets('start banner: up before playback, fades on play, never back for that media', (tester) async {
await tester.pumpWidget(const MaterialApp(home: SizedBox(width: 1280, height: 720, child: StartBanner())));
final card = find.byKey(const ValueKey('start-card-title'));
expect(card, findsNothing);
coord.serverState.value = held([mv('a', 'Alex')]);
await tester.pump();
expect(card, findsOneWidget);
expect(find.text(coord.nowPlaying.value!.title), findsWidgets);
expect(tester.widget<AnimatedOpacity>(find.byKey(const ValueKey('start-banner'))).opacity, 1);
coord.serverState.value = held([mv('a', 'Alex', ready: true)], transport: 'playing');
await tester.pump();
expect(tester.widget<AnimatedOpacity>(find.byKey(const ValueKey('start-banner'))).opacity, 0);
await tester.pumpAndSettle();
expect(card, findsNothing);
// Paused back at the start: still gone.
coord.serverState.value = held([mv('a', 'Alex')]);
await tester.pumpAndSettle();
expect(card, findsNothing);
// The next media gets its own card.
coord.serverState.value = held([mv('a', 'Alex')], mediaId: 2);
await tester.pump();
expect(card, findsOneWidget);
// Joined mid-way: none.
coord.serverState.value = held([mv('a', 'Alex')], mediaId: 3, position: 600);
await tester.pumpAndSettle();
expect(card, findsNothing);
});
group('no overflow', () {
const sizes = [Size(900, 600), Size(2560, 1080)];
final long = NowPlaying(title: 'An Extremely Long Series Title ' * 4, episode: 'S12 E104 · ${'Episode ' * 20}',
posterHash: 'p', bannerHash: 'b');
final crowd = [
for (var i = 0; i < 9; i++) mv('$i', 'Someone With A Long Name $i'),
];
Future<void> sized(WidgetTester tester, Size size, Widget child) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(theme: ThemeData.dark(), home: Scaffold(body: child)));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
}
for (final size in sizes) {
testWidgets('session player, status pill: ${size.width.toInt()}', (tester) async {
coord.playback.alive.value = true;
coord.serverState.value = held(crowd);
await sized(
tester,
size,
SessionPlayer(
mode: PlayerMode.full,
fullscreen: false,
onExpand: () {},
onCollapse: () {},
onClose: () {},
onToggleFullscreen: () {},
onToggleChat: () {},
onChangeVideo: () {},
onOpenIntegrations: () {},
onLeave: () {},
windowControls: false,
videoBuilder: (context, controls) => const ColoredBox(color: Colors.black),
),
);
expect(find.byKey(const ValueKey('player-status')), findsOneWidget);
expect(find.textContaining('Waiting for Someone With A Long Name 0'), findsOneWidget);
});
testWidgets('uosc top bar and start card: ${size.width.toInt()}', (tester) async {
debugArtBytes = (hash) => png;
await sized(
tester,
size,
Stack(fit: StackFit.expand, children: [
StartCard(now: long),
UoscControls(
topLeft: NowPlayingBlock(now: long),
top: Row(mainAxisSize: MainAxisSize.min, children: [
for (final i in [Icons.chat_bubble_outline, Icons.fullscreen, Icons.close])
UoscButton(icon: i, tooltip: '', onPressed: () {}),
]),
timeline: (context, expanded) =>
UoscTimeline(position: Duration.zero, duration: const Duration(minutes: 24), expanded: expanded, onSeek: (_) {}),
),
]),
);
expect(find.byType(ArtCover), findsNWidgets(2));
});
testWidgets('standard top bar row: ${size.width.toInt()}', (tester) async {
await sized(
tester,
size,
Align(
alignment: Alignment.topCenter,
child: SizedBox(
height: 72,
child: Row(children: [
Expanded(child: NowPlayingBlock(now: long)),
for (final i in [Icons.video_library_outlined, Icons.chat_bubble_outline, Icons.close_fullscreen])
IconButton(onPressed: () {}, icon: Icon(i)),
]),
),
),
);
});
}
});
}
@@ -1,8 +1,10 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/schedule/anilist.dart';
import 'package:watch_together/src/session/media_art.dart';
import 'schedule_fake_server.dart';
@@ -163,6 +165,52 @@ void main() {
await expectLater(client().schedule(from, to), throwsA(isA<AniListException>()));
});
test('searchMedia: one request per title, 404 is no match, art via aniListArt', () async {
server.handler = (req, res) async {
final search = ((jsonDecode(req.body) as Map)['variables'] as Map)['search'];
if (search != 'Frieren') {
json(res, {'errors': [{'message': 'Not Found.', 'status': 404}], 'data': {'Media': null}}, status: 404);
return;
}
json(res, {
'data': {
'Media': {
'id': 154587,
'title': {'romaji': 'Sousou no Frieren', 'english': 'Frieren: Beyond Journey\'s End'},
'coverImage': {'extraLarge': 'https://img/xl.jpg', 'large': 'https://img/l.jpg'},
'bannerImage': 'https://img/banner.jpg',
},
},
});
};
final c = client();
final m = await c.searchMedia('Frieren');
expect(m!.id, 154587);
expect(m.title, "Frieren: Beyond Journey's End");
expect(m.cover, 'https://img/xl.jpg');
expect(m.coverSmall, 'https://img/l.jpg');
expect(m.banner, 'https://img/banner.jpg');
await c.searchMedia('Frieren');
expect(server.seen, hasLength(1), reason: 'cached per title');
expect(await c.searchMedia('Nothing Like It'), isNull);
expect(await c.searchMedia(' '), isNull);
expect(server.seen, hasLength(2));
// Files: the release name's series; the extraLarge cover too big falls
// back to large.
final jpeg = Uint8List.fromList([0xFF, 0xD8, 0xFF, 1, 2, 3]);
final asked = <String>[];
final art = await aniListArt(c, '[SubsPlease] Frieren - 05 (1080p) [ABCD1234].mkv', fetch: (url, headers, max) async {
asked.add('$url');
return url.path == '/xl.jpg' ? null : jpeg;
});
expect(art!.poster, jpeg);
expect(art.banner, jpeg);
expect(asked, unorderedEquals(['https://img/xl.jpg', 'https://img/banner.jpg', 'https://img/l.jpg']));
expect(await aniListArt(c, 'Nothing Like It.mkv', fetch: (_, _, _) async => jpeg), isNull);
c.close();
});
test('parseHexColor', () {
expect(parseHexColor('#000000')?.toARGB32(), 0xff000000);
expect(parseHexColor('e4a15d'), isNull);
+3 -2
View File
@@ -290,12 +290,13 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('waiting pill: who is not ready, or you', (tester) async {
testWidgets('status pill: who is not ready, with the ready count', (tester) async {
coord.playback.alive.value = true;
await pumpShell(tester, const Size(1280, 800));
online([member('a', 'Alex', ready: true), member('b', 'Bob'), member('c', 'Cleo')]);
await tester.pumpAndSettle();
expect(find.text('Waiting for Bob, Cleo'), findsOneWidget);
expect(find.text('Waiting for Bob and Cleo'), findsOneWidget);
expect(find.text('1/3 ready'), findsOneWidget);
});
test('toasts: feed toasts carry the actor, the rest are the app\'s', () {