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
+1892
-236
No files matched your search
@@ -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