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

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

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

No files matched your search

+3
View File
@@ -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;
}
+4 -1
View File
@@ -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;
}
+26
View File
@@ -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;
+69 -1
View File
@@ -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,
+64
View File
@@ -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});
});
});
}