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:
1 parent
d092ea371a
commit
b534c8c7ea
29 files changed
+1891
-235
No files matched your search
@@ -56,9 +56,12 @@ corner; double-click or ⤢ makes it big again, × stops watching (you stay in
|
||||
the server, muted) and the strip's screen button brings it back. F or
|
||||
double-click on the picture goes fullscreen (the strip hides, the party
|
||||
shows in a corner while paused, the window buttons sit in the player's top
|
||||
bar while its controls are up), Esc leaves it. While playback waits, a
|
||||
pill under the picture says for whom ("Waiting for you · press Space when
|
||||
ready"). Chat and activity open in a panel on the right (chat button or
|
||||
bar while its controls are up), Esc leaves it. The controls show the
|
||||
show's cover, title and episode top left (guests get the cover too); before
|
||||
a video first plays, its banner fills the picture until playback starts.
|
||||
One pill above the controls says what playback waits for ("Press Space
|
||||
when you're ready", "Waiting for Bob and Mika · 2/3 ready", "Bob is
|
||||
loading…") or what went wrong, with Retry or Cancel when it helps. Chat and activity open in a panel on the right (chat button or
|
||||
Tab); joins, leaves, chat and other news also pop up bottom left with the
|
||||
person's avatar.
|
||||
|
||||
@@ -181,7 +184,7 @@ chapter title and the time there. With the controls hidden it stays as a
|
||||
thin line (unless "Progress line when hidden" is off). Above it flat buttons: ≡ menu, Subtitles and Audio (with the
|
||||
track count), the shader badge (the active Anime4K mode, e.g. "A HQ"),
|
||||
ready, chapters, 1:30, previous / next episode, change video, volume, ⚙
|
||||
and fullscreen; chat and back-to-library top right. Keys and skip buttons work
|
||||
and fullscreen; the title top left, chat and back-to-library top right. Keys and skip buttons work
|
||||
the same.
|
||||
|
||||
Skipping: during the opening a "Skip OP" button floats in the corner (it
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
final data = j is Map && j['data'] is Map ? j['data'] as Map : null;
|
||||
return (data, status, j);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
+26
-5
@@ -604,9 +604,22 @@ sideways until an end, then the page).
|
||||
The player keys (Settings → Keyboard shortcuts) work while the player is
|
||||
full and nothing is in front of it. Something coming up while the player
|
||||
is closed opens it: full right after joining or after putting it up
|
||||
here, else mini. While held, a pill under the picture names who
|
||||
everyone waits for, or says "Waiting for you · press Space" (a click
|
||||
marks you ready); the mini shows it in its hover bar. ⚙ in both
|
||||
here, else mini. Both control designs show `NowPlayingTitle` top left
|
||||
(`now_playing_title.dart`: 2:3 cover, title, episode line from
|
||||
`coordinator.nowPlaying`, art through `artBytes`; it fades through when
|
||||
the media changes); the mini shows title and episode without the cover.
|
||||
Until playback first starts on a media (server not playing, position
|
||||
under 1 s) `StartBanner` (`start_banner.dart`) lies under the controls:
|
||||
the banner (else a blur of the cover, else dark), darkened, with cover,
|
||||
big title and episode; it fades out on play and stays gone for that
|
||||
media. One status pill (`player_status.dart`) sits bottom centre above
|
||||
the controls: `playerStatusFor` (pure) picks the most pressing of
|
||||
removed, reconnecting (Cancel), a warn/error health message (Retry),
|
||||
connecting (only after 2 s), finished, someone loading (their avatar
|
||||
and buffer progress), you not ready (a click marks you ready) and who
|
||||
everyone waits for (avatars, "2/3 ready"); nothing otherwise, info
|
||||
never. Frosted glass, colour only for problems; it fades through
|
||||
between kinds. The mini shows its short line in its hover bar. ⚙ in both
|
||||
control designs opens the player settings card (`controls/
|
||||
player_settings.dart`, `showPopover`): player design, progress line
|
||||
when hidden, upscaling shader, hardware decoding (set live on mpv's
|
||||
@@ -686,7 +699,7 @@ Session controls (the Standard player design): media_kit's
|
||||
`screens/session/player_controls.dart`); its own double-click and keys are
|
||||
off (its double-click pushes a fullscreen route with a second `Video`), the
|
||||
picture's double-click is a raw pointer observer (`DoubleClickListener`).
|
||||
The top bar holds the title, Change video, chat and back-to-library
|
||||
The top bar holds the now-playing title, Change video, chat and back-to-library
|
||||
(fullscreen: exit, then the window buttons); the bottom bar ends with the
|
||||
shader button, ⚙ and fullscreen. Its seek bar is off; ours
|
||||
(`ChapterSeekBar`: chapter diamonds, hover time with the chapter title)
|
||||
@@ -714,7 +727,8 @@ change video, chat & activity, reload, mark everyone ready, fullscreen,
|
||||
leave),
|
||||
subtitles and audio with track counts, a shader badge ("A HQ", opening the
|
||||
shared `showShaderMenu`), ready, mark everyone ready, reload; 1:30,
|
||||
chapters, previous / next episode, change video, volume, ⚙, fullscreen; chat
|
||||
chapters, previous / next episode, change video, volume, ⚙, fullscreen; the
|
||||
now-playing title top left (`UoscControls.topLeft`, with a top gradient), chat
|
||||
and back-to-library (fullscreen: exit) top right. Menus (`showUoscMenu`: a popup route above the button, the
|
||||
current row highlighted) list tracks and chapters with start times. All
|
||||
actions are the coordinator's (seeks through `player.seek`, the user path;
|
||||
@@ -791,3 +805,10 @@ this repo, written by `tool/release.sh`.
|
||||
the bytes of current members in memory and serves them at `/a/...`, which
|
||||
clients fetch once per hash (`AvatarCache`). Uplink per broadcast stays
|
||||
small whatever the picture size (cap 256 KiB base64 per avatar).
|
||||
- Media art the same way (`/i/...`, `ArtCache` as `coordinator.art`): the
|
||||
member who swapped the media in fetches poster and banner once the state
|
||||
shows its swap landed (`media_art.dart`: Jellyfin series/item images with
|
||||
its own account, else an AniList title search on the parsed release
|
||||
name) and sends `art`, so guests without Jellyfin get covers too.
|
||||
`coordinator.nowPlaying` derives title, episode line and art hashes from
|
||||
`state.media` (`now_playing_info.dart`).
|
||||
+28
-3
@@ -24,7 +24,8 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
|
||||
`GET /j/<sid>/<token>/<itemId>` relays the current Jellyfin media (see
|
||||
"Jellyfin media" below). `/r/<sid>/<token>/...` is the relay of a
|
||||
member's media (see "Relay" below). `GET /a/<sid>/<token>/<hash>` serves
|
||||
a member's picture (see "Avatars" below).
|
||||
a member's picture (see "Avatars" below), `GET /i/<sid>/<token>/<hash>`
|
||||
the current media's art (see "Media art" below).
|
||||
|
||||
## Client to server
|
||||
|
||||
@@ -44,6 +45,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
|
||||
{"t":"chat","text":"..."}
|
||||
{"t":"kick","target":"<memberId>","reason":""}
|
||||
{"t":"profile","name":"...","avatar":"<base64>"}
|
||||
{"t":"art","mediaId":2,"poster":"<base64>","banner":"<base64>"}
|
||||
```
|
||||
|
||||
Field notes:
|
||||
@@ -77,6 +79,7 @@ Field notes:
|
||||
that is absent (or null) stays as it is; `avatar: ""` removes the picture.
|
||||
Same rules as `hello.user`; a too large avatar gets `err`
|
||||
`rejected: avatar too large` (the socket stays open, nothing changes).
|
||||
- `art`: pictures of the current media (see "Media art").
|
||||
- `chat.text` is cut to 500 characters.
|
||||
- `swap.media.url` is the link; `name`, `size`, `duration` are informational.
|
||||
`swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is
|
||||
@@ -91,7 +94,7 @@ Field notes:
|
||||
"state":{<state>}}
|
||||
{"t":"state","eventId":7,"transport":"playing"|"held"|"finished","position":12.3,
|
||||
"serverTime":"<ISO-8601 UTC>","mediaId":1,
|
||||
"media":{name,size,duration,url?,jellyfin?}|absent,
|
||||
"media":{name,size,duration,url?,jellyfin?,relay?,swapBy?,art?}|absent,
|
||||
"members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?,
|
||||
"host"?}],
|
||||
"seekBy":"m1","seekSeq":3,"relay":true}
|
||||
@@ -116,7 +119,9 @@ Field notes:
|
||||
`eventId` increases on seek, swap and transport changes.
|
||||
`mediaId` increases on swap. `media.relay` is present for relayed media
|
||||
(see "Relay"). `media.duration` is 0 until a client reports
|
||||
it. `seekBy` (member id) and `seekSeq` are present only while the current
|
||||
it. `media.swapBy`: member id of who swapped it in (omitted once that
|
||||
member left, and for the media the server started with). `media.art`:
|
||||
see "Media art". `seekBy` (member id) and `seekSeq` are present only while the current
|
||||
`eventId` came from a seek. `relay` (omitted when false): the server can
|
||||
relay the current Jellyfin media. `members[].jf` is the member's last
|
||||
`report.jf`, omitted when it never sent one. `members[].avatarHash`
|
||||
@@ -150,6 +155,26 @@ Field notes:
|
||||
picture, 410 unknown session. Pictures go with their member (leave,
|
||||
change, removal).
|
||||
|
||||
## Media art
|
||||
|
||||
```
|
||||
client {"t":"art","mediaId":2,"poster":"<base64>","banner":"<base64>"}
|
||||
state media.art = {"poster":"<hash>","banner":"<hash>"}
|
||||
```
|
||||
|
||||
- A portrait poster and a wide banner for the current media, so every
|
||||
member (guests without Jellyfin included) can show them. Sent by the
|
||||
member who made the current swap (`media.swapBy`) or the host, for the
|
||||
current `mediaId`; from anyone else, or for another `mediaId`, it is
|
||||
ignored. Each field is optional; one that is not a picture (same check
|
||||
as avatars) or larger than its cap (poster 256 KiB, banner 768 KiB of
|
||||
decoded bytes) counts as none. A later `art` replaces the earlier one
|
||||
whole; any swap clears it.
|
||||
- `media.art` carries hashes like `avatarHash`; a key is omitted when that
|
||||
picture is absent, `art` when both are.
|
||||
- `GET /i/<sid>/<token>/<hash>`: as `/a/` (see "Avatars"); 404 when the
|
||||
current media has no such art.
|
||||
|
||||
## Jellyfin media
|
||||
|
||||
```
|
||||
|
||||
@@ -38,6 +38,9 @@ abstract class RelayConstants {
|
||||
// over-size for private-server use. PNG keeps transparency; JPEG did
|
||||
// not. Only the hash rides in `state`; the bytes are fetched once.
|
||||
static const int maxAvatarBytes = 256 * 1024;
|
||||
/// Decoded-byte caps of the current media's art (`art` message).
|
||||
static const int maxPosterBytes = 256 * 1024;
|
||||
static const int maxBannerBytes = 768 * 1024;
|
||||
static const int maxNameLength = 32;
|
||||
static const int maxChatLength = 500;
|
||||
}
|
||||
@@ -132,7 +132,8 @@ enum JellyfinImageType {
|
||||
primary('Primary'),
|
||||
backdrop('Backdrop'),
|
||||
thumb('Thumb'),
|
||||
logo('Logo');
|
||||
logo('Logo'),
|
||||
banner('Banner');
|
||||
|
||||
final String apiName;
|
||||
const JellyfinImageType(this.apiName);
|
||||
@@ -361,6 +362,8 @@ class JellyfinItem {
|
||||
if (parentLogoItemId != null && parentLogoImageTag != null) {
|
||||
return JellyfinImageRef(parentLogoItemId!, type, parentLogoImageTag!);
|
||||
}
|
||||
case JellyfinImageType.banner:
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -478,6 +478,32 @@ class ProfileIntent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cover art for the current media, sent by the member who swapped it in
|
||||
/// (or the host). Base64 picture bytes; null = none.
|
||||
class ArtMessage {
|
||||
final int mediaId;
|
||||
final String? poster;
|
||||
final String? banner;
|
||||
const ArtMessage({required this.mediaId, this.poster, this.banner});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
't': 'art',
|
||||
'mediaId': mediaId,
|
||||
if (poster != null) 'poster': poster,
|
||||
if (banner != null) 'banner': banner,
|
||||
};
|
||||
|
||||
factory ArtMessage.fromJson(Map<String, Object?> json) {
|
||||
if (json['t'] != 'art') throw const ProtocolException('not an art message');
|
||||
final poster = json['poster'];
|
||||
final banner = json['banner'];
|
||||
return ArtMessage(
|
||||
mediaId: finiteInt(json['mediaId'], 'mediaId'),
|
||||
poster: poster is String ? poster : null,
|
||||
banner: banner is String ? banner : null);
|
||||
}
|
||||
}
|
||||
|
||||
class MemberView {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
@@ -97,6 +97,27 @@ class EngineMember {
|
||||
);
|
||||
}
|
||||
|
||||
/// Picture bytes from base64 when they decode, are at most [maxBytes] and
|
||||
/// are a known image type (see [avatarImageType]); else null.
|
||||
Uint8List? decodePicture(String base64, int maxBytes) {
|
||||
// Base64 is 4 chars per 3 bytes: skip decoding what cannot fit.
|
||||
if (base64.isEmpty || base64.length > (maxBytes + 2) ~/ 3 * 4) return null;
|
||||
try {
|
||||
final bytes = base64Decode(base64);
|
||||
if (bytes.length > maxBytes || avatarImageType(bytes) == null) return null;
|
||||
return bytes;
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// One picture of the current media's art, by content hash.
|
||||
class ArtImage {
|
||||
final String hash;
|
||||
final Uint8List bytes;
|
||||
ArtImage(this.bytes) : hash = avatarHashOf(bytes);
|
||||
}
|
||||
|
||||
class EngineFeed {
|
||||
final List<FeedMessage> pending = [];
|
||||
void add(FeedMessage message) => pending.add(message);
|
||||
@@ -120,6 +141,12 @@ class SyncEngine {
|
||||
DateTime serverTime;
|
||||
int mediaId = 1;
|
||||
HelloMedia? media;
|
||||
/// Token of the member who made the current swap; null before any swap.
|
||||
String? swapBy;
|
||||
/// Art of the current media (`art`), served at `/i/...`; only the hashes
|
||||
/// ride in `state.media.art`. Every swap clears it.
|
||||
ArtImage? poster;
|
||||
ArtImage? banner;
|
||||
int _nextId = 1;
|
||||
// Who sent the seek that produced [_seekEventId], for the echo check.
|
||||
String? _seekBy;
|
||||
@@ -207,6 +234,38 @@ class SyncEngine {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Art for the current media from [token]: only from the member who made
|
||||
/// the current swap (or the host) and only for the current [forMediaId].
|
||||
/// It replaces any earlier art; an absent or invalid field is none.
|
||||
/// Returns whether it was taken.
|
||||
bool applyArt(String token, int forMediaId, {String? poster, String? banner}) {
|
||||
final member = members[token];
|
||||
if (member == null || media == null || forMediaId != mediaId) return false;
|
||||
if (token != swapBy && !member.host) return false;
|
||||
_seen(member);
|
||||
final p = poster == null ? null : decodePicture(poster, RelayConstants.maxPosterBytes);
|
||||
final b = banner == null ? null : decodePicture(banner, RelayConstants.maxBannerBytes);
|
||||
this.poster = p == null ? null : ArtImage(p);
|
||||
this.banner = b == null ? null : ArtImage(b);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Bytes of the current media's art by hash.
|
||||
Uint8List? artFor(String hash) {
|
||||
if (hash.isEmpty) return null;
|
||||
if (poster?.hash == hash) return poster!.bytes;
|
||||
if (banner?.hash == hash) return banner!.bytes;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// `state.media.art`; null when there is none.
|
||||
Map<String, Object?>? get artJson => poster == null && banner == null
|
||||
? null
|
||||
: {
|
||||
if (poster != null) 'poster': poster!.hash,
|
||||
if (banner != null) 'banner': banner!.hash,
|
||||
};
|
||||
|
||||
/// A reconnect took over [token]'s member (see `hello.resume`).
|
||||
void applyResume(String token) {
|
||||
final member = members[token];
|
||||
@@ -301,6 +360,9 @@ class SyncEngine {
|
||||
final actor = members[actorToken];
|
||||
mediaId++;
|
||||
media = next;
|
||||
swapBy = actorToken;
|
||||
poster = null;
|
||||
banner = null;
|
||||
position = 0;
|
||||
serverTime = now;
|
||||
eventId++;
|
||||
@@ -409,7 +471,13 @@ class SyncEngine {
|
||||
position: position,
|
||||
serverTime: serverTime,
|
||||
mediaId: mediaId,
|
||||
media: media?.toJson(),
|
||||
media: media == null
|
||||
? null
|
||||
: {
|
||||
...media!.toJson(),
|
||||
if (members[swapBy]?.id case final id?) 'swapBy': id,
|
||||
if (artJson != null) 'art': artJson,
|
||||
},
|
||||
members: members.values.map((m) => m.view(stale: isStale(m))).toList(),
|
||||
seekBy: bySeek ? _seekBy : null,
|
||||
seekSeq: bySeek ? _seekSeq : null,
|
||||
|
||||
@@ -606,4 +606,68 @@ void main() {
|
||||
expect(engine.members['a']!.avatarHash, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('media art', () {
|
||||
final jpeg = base64Encode([0xFF, 0xD8, 0xFF, ...List<int>.generate(61, (i) => i)]);
|
||||
final png = base64Encode([0x89, 0x50, 0x4E, 0x47, ...List<int>.generate(60, (i) => 255 - i)]);
|
||||
const media = HelloMedia(name: 'Show - 01.mkv', size: 1, duration: 0, url: 'http://x/a.mkv');
|
||||
Map<String, Object?>? art() => engine.broadcast().media?['art'] as Map<String, Object?>?;
|
||||
|
||||
test('swapper sends art for the current media; state carries hashes only', () {
|
||||
join('a', 'alice');
|
||||
join('b', 'bob');
|
||||
engine.applySwap('a', media);
|
||||
expect(art(), isNull, reason: 'omitted when empty');
|
||||
expect(engine.broadcast().media?['swapBy'], 'm1');
|
||||
expect(engine.applyArt('a', engine.mediaId, poster: jpeg, banner: png), isTrue);
|
||||
final hashes = art()!;
|
||||
expect(hashes['poster'], avatarHashOf(base64Decode(jpeg)));
|
||||
expect(hashes['banner'], avatarHashOf(base64Decode(png)));
|
||||
expect(engine.artFor(hashes['poster'] as String), base64Decode(jpeg));
|
||||
expect(engine.artFor('0123456789abcdef'), isNull);
|
||||
expect(jsonEncode(engine.broadcast().toJson()), isNot(contains(jpeg)));
|
||||
// A later art replaces it; a field left out is none.
|
||||
expect(engine.applyArt('a', engine.mediaId, poster: png), isTrue);
|
||||
expect(art(), {'poster': avatarHashOf(base64Decode(png))});
|
||||
});
|
||||
|
||||
test('rejects other members, stale mediaId; host may send', () {
|
||||
join('a', 'alice');
|
||||
join('b', 'bob');
|
||||
join('h', 'host');
|
||||
engine.members['h']!.host = true;
|
||||
expect(engine.applyArt('a', engine.mediaId, poster: jpeg), isFalse, reason: 'no media yet');
|
||||
engine.applySwap('a', media);
|
||||
final id = engine.mediaId;
|
||||
expect(engine.applyArt('b', id, poster: jpeg), isFalse);
|
||||
expect(engine.applyArt('a', id - 1, poster: jpeg), isFalse);
|
||||
expect(art(), isNull);
|
||||
expect(engine.applyArt('h', id, poster: jpeg), isTrue);
|
||||
expect(art(), contains('poster'));
|
||||
});
|
||||
|
||||
test('bad bytes and oversize fields are dropped; any swap clears', () {
|
||||
join('a', 'alice');
|
||||
engine.applySwap('a', media);
|
||||
final big = base64Encode([0xFF, 0xD8, 0xFF, ...List<int>.filled(RelayConstants.maxPosterBytes, 1)]);
|
||||
engine.applyArt('a', engine.mediaId,
|
||||
poster: big, banner: base64Encode(List<int>.generate(64, (i) => i)));
|
||||
expect(art(), isNull);
|
||||
engine.applyArt('a', engine.mediaId, poster: 'not base64!', banner: big);
|
||||
expect(art(), {'banner': avatarHashOf(base64Decode(big))}, reason: 'banner cap is larger');
|
||||
join('b', 'bob');
|
||||
engine.applySwap('b', media);
|
||||
expect(art(), isNull);
|
||||
expect(engine.artFor(avatarHashOf(base64Decode(big))), isNull);
|
||||
});
|
||||
|
||||
test('ArtMessage round trip', () {
|
||||
final m = ArtMessage.fromJson(Map<String, Object?>.from(
|
||||
jsonDecode(jsonEncode(ArtMessage(mediaId: 3, poster: jpeg).toJson())) as Map));
|
||||
expect(m.mediaId, 3);
|
||||
expect(m.poster, jpeg);
|
||||
expect(m.banner, isNull);
|
||||
expect(const ArtMessage(mediaId: 1).toJson(), {'t': 'art', 'mediaId': 1});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:relic/relic.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
@@ -162,9 +163,17 @@ class ServerHub {
|
||||
/// `GET /a/<sid>/<token>/<hash>`: a current member's picture by its
|
||||
/// content hash, for members (valid, unkicked token) only. The bytes for a
|
||||
/// hash never change, so clients may cache them for good.
|
||||
void mountAvatarRoute(RelicApp app, ServerHub hub) {
|
||||
void mountAvatarRoute(RelicApp app, ServerHub hub) =>
|
||||
_mountPictureRoute(app, hub, 'a', hub.engine.avatarFor);
|
||||
|
||||
/// `GET /i/<sid>/<token>/<hash>`: the current media's art (`art`) by hash,
|
||||
/// same rules as [mountAvatarRoute].
|
||||
void mountArtRoute(RelicApp app, ServerHub hub) =>
|
||||
_mountPictureRoute(app, hub, 'i', hub.engine.artFor);
|
||||
|
||||
void _mountPictureRoute(RelicApp app, ServerHub hub, String prefix, Uint8List? Function(String hash) lookup) {
|
||||
Response status(int code) => Response(code, body: Body.fromString('status $code'));
|
||||
app.get('/a/:sid/:token/:hash', (final req) {
|
||||
app.get('/$prefix/:sid/:token/:hash', (final req) {
|
||||
final sid = req.pathParameters.raw[#sid] ?? '';
|
||||
final token = req.pathParameters.raw[#token] ?? '';
|
||||
final hash = req.pathParameters.raw[#hash] ?? '';
|
||||
@@ -178,7 +187,7 @@ void mountAvatarRoute(RelicApp app, ServerHub hub) {
|
||||
default:
|
||||
break;
|
||||
}
|
||||
final bytes = hub.engine.avatarFor(hash);
|
||||
final bytes = lookup(hash);
|
||||
final type = bytes == null ? null : avatarImageType(bytes);
|
||||
if (bytes == null || type == null) return status(404);
|
||||
return Response.ok(
|
||||
@@ -402,6 +411,12 @@ void mountSyncRoute(
|
||||
if (name != null) viewer.name = name;
|
||||
hub.engine.applyProfile(token, name: name, avatar: avatar);
|
||||
hub.changed();
|
||||
case 'art':
|
||||
if (token == null) return;
|
||||
final art = ArtMessage.fromJson(decoded);
|
||||
if (hub.engine.applyArt(token, art.mediaId, poster: art.poster, banner: art.banner)) {
|
||||
hub.broadcastState();
|
||||
}
|
||||
case 'report':
|
||||
if (token == null) return;
|
||||
final report = ReportMessage.fromJson(decoded);
|
||||
|
||||
@@ -65,6 +65,7 @@ class SyncServer {
|
||||
mountSessionMediaRoute(_app, sessions);
|
||||
mountRelayRoutes(_app, sessions, () => hub.relay);
|
||||
mountAvatarRoute(_app, hub);
|
||||
mountArtRoute(_app, hub);
|
||||
mountJellyfinRelayRoute(
|
||||
_app, sessions, () => hub.jellyfin, () => _relayClient ??= HttpClient());
|
||||
// advertiseHost is only an explicit override; otherwise echo what the client dialled.
|
||||
|
||||
@@ -390,6 +390,98 @@ void main() {
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
test('art: swapper only, current mediaId, hashes in state, /i serves', () async {
|
||||
final jpeg = Uint8List.fromList([0xFF, 0xD8, 0xFF, ...List<int>.generate(200, (i) => (i * 3) % 256)]);
|
||||
final png = Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, ...List<int>.generate(200, (i) => i % 256)]);
|
||||
final clients = <WebSocketChannel>[];
|
||||
final subs = <StreamSubscription<Object?>>[];
|
||||
Future<(WebSocketChannel, List<Map<String, Object?>>, String)> connect(String name) async {
|
||||
final ch = WebSocketChannel.connect(Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
|
||||
final messages = <Map<String, Object?>>[];
|
||||
clients.add(ch);
|
||||
subs.add(ch.stream.listen((m) => messages.add(Map<String, Object?>.from(jsonDecode('$m') as Map))));
|
||||
ch.sink.add(jsonEncode(HelloMessage(
|
||||
proto: RelayConstants.protoVersion,
|
||||
secret: 'shh',
|
||||
user: HelloUser(name: name),
|
||||
media: const HelloMedia(name: 'f.bin', size: 8192, duration: 10.0)).toJson()));
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (!messages.any((m) => m['t'] == 'welcome')) {
|
||||
if (DateTime.now().isAfter(deadline)) fail('no welcome');
|
||||
await Future.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
return (ch, messages, messages.firstWhere((m) => m['t'] == 'welcome')['token'] as String);
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>> waitFor(
|
||||
List<Map<String, Object?>> messages, bool Function(Map<String, Object?>) test) async {
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
for (final m in messages) {
|
||||
if (test(m)) return m;
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
fail('timed out; got $messages');
|
||||
}
|
||||
|
||||
Map<String, Object?>? artOf(Map<String, Object?> state) =>
|
||||
(state['media'] as Map?)?['art'] as Map<String, Object?>?;
|
||||
Future<HttpClientResponse> artGet(String token, String hash) async =>
|
||||
(await http.get('127.0.0.1', server.boundPort, '/i/default/$token/$hash')).close();
|
||||
|
||||
try {
|
||||
final (a, aMsgs, aToken) = await connect('alice');
|
||||
final (b, bMsgs, bToken) = await connect('bob');
|
||||
a.sink.add(jsonEncode(const SwapIntent(
|
||||
media: HelloMedia(name: 'Show - 01.mkv', size: 1, duration: 0, url: 'http://example.com/a.mkv')).toJson()));
|
||||
final swapped = await waitFor(bMsgs, (m) => m['t'] == 'state' && (m['media'] as Map?)?['name'] == 'Show - 01.mkv');
|
||||
final mediaId = swapped['mediaId'] as int;
|
||||
|
||||
// Not the swapper, or a stale mediaId: ignored.
|
||||
b.sink.add(jsonEncode(ArtMessage(mediaId: mediaId, poster: base64Encode(png)).toJson()));
|
||||
a.sink.add(jsonEncode(ArtMessage(mediaId: mediaId - 1, poster: base64Encode(png)).toJson()));
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
expect(server.hub.engine.poster, isNull);
|
||||
|
||||
a.sink.add(jsonEncode(ArtMessage(mediaId: mediaId, poster: base64Encode(jpeg), banner: base64Encode(png)).toJson()));
|
||||
final withArt = await waitFor(bMsgs, (m) => m['t'] == 'state' && artOf(m) != null);
|
||||
final art = artOf(withArt)!;
|
||||
expect(art, {'poster': avatarHashOf(jpeg), 'banner': avatarHashOf(png)});
|
||||
|
||||
// Any member (a guest without Jellyfin) fetches it.
|
||||
final ok = await artGet(bToken, art['poster'] as String);
|
||||
expect(ok.statusCode, 200);
|
||||
expect(ok.headers.contentType?.mimeType, 'image/jpeg');
|
||||
expect(ok.headers.value('cache-control'), contains('immutable'));
|
||||
expect(await ok.expand((c) => c).toList(), jpeg);
|
||||
final unknown = await artGet(bToken, '0123456789abcdef');
|
||||
expect(unknown.statusCode, 404);
|
||||
await unknown.drain();
|
||||
final noToken = await artGet('nope', art['poster'] as String);
|
||||
expect(noToken.statusCode, 401);
|
||||
await noToken.drain();
|
||||
|
||||
// Any swap clears the art.
|
||||
b.sink.add(jsonEncode(const SwapIntent(
|
||||
media: HelloMedia(name: 'Other.mkv', size: 1, duration: 0, url: 'http://example.com/b.mkv')).toJson()));
|
||||
final cleared = await waitFor(aMsgs, (m) => m['t'] == 'state' && (m['media'] as Map?)?['name'] == 'Other.mkv');
|
||||
expect(artOf(cleared), isNull);
|
||||
final gone = await artGet(aToken, art['poster'] as String);
|
||||
expect(gone.statusCode, 404);
|
||||
await gone.drain();
|
||||
} finally {
|
||||
for (final sub in subs) {
|
||||
await sub.cancel();
|
||||
}
|
||||
for (final ch in clients) {
|
||||
try {
|
||||
await ch.sink.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('profile: rename + avatar feed, state carries hash, /a gated', () async {
|
||||
final png = Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, ...List<int>.generate(296, (i) => (i * 7) % 256)]);
|
||||
final ch = WebSocketChannel.connect(
|
||||
|
||||
Reference in new issue
Block a user