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,
|
||||
|
||||
Reference in new issue
Block a user