1.0 library + player settings: Jellyfin/Link/Files library with details panel, player gear, settings cards, host flag
Library: source switch, selectable cards with a details panel, one merged Continue row, Latest grouped per series without re-releases, Link page with previews and recent links, Files embedded. Player: gear popover in both designs, progress line while controls hide, live hwdec. Settings rebuilt as cards. State marks the host member (proto stays 9, unreleased); hosting shows LAN invite addresses; window buttons in fullscreen. fadeThrough keeps the outgoing view's state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
99cda7ed04
commit
d092ea371a
54 files changed
+4963
-1825
No files matched your search
@@ -180,7 +180,8 @@ class JellyfinClient {
|
||||
final JellyfinHttp _http;
|
||||
|
||||
/// Fields requested for list items: enough for cards and detail headers.
|
||||
static const listFields = 'PrimaryImageAspectRatio,Overview,ChildCount,RecursiveItemCount';
|
||||
static const listFields =
|
||||
'PrimaryImageAspectRatio,Overview,ChildCount,RecursiveItemCount,DateCreated';
|
||||
static const _imageParams = {
|
||||
'enableImageTypes': 'Primary,Backdrop,Thumb,Logo',
|
||||
'imageTypeLimit': '1',
|
||||
@@ -350,6 +351,22 @@ class JellyfinClient {
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Episodes and movies the user finished, most recently played first
|
||||
/// ([JellyfinUserData.lastPlayedDate] set): when each series was last
|
||||
/// watched, which [resume] and [nextUp] do not tell for fully played
|
||||
/// episodes.
|
||||
Future<List<JellyfinItem>> recentlyPlayed({int limit = 100}) async => (await items(
|
||||
JellyfinItemQuery(
|
||||
includeItemTypes: const [JellyfinItemType.episode, JellyfinItemType.movie],
|
||||
recursive: true,
|
||||
filters: const [JellyfinFilter.isPlayed],
|
||||
sortBy: const [JellyfinSortBy.datePlayed],
|
||||
descending: true,
|
||||
limit: limit,
|
||||
),
|
||||
))
|
||||
.items;
|
||||
|
||||
/// A page of library items; see [JellyfinItemQuery].
|
||||
Future<JellyfinItemPage> items(JellyfinItemQuery q) async {
|
||||
final page = JellyfinItemPage.fromJson(_map(await _json('GET', '/Items', query: {
|
||||
|
||||
@@ -198,6 +198,9 @@ class JellyfinItem {
|
||||
final String? overview;
|
||||
final int? productionYear;
|
||||
final DateTime? premiereDate;
|
||||
/// When the item was added to the library (needs `DateCreated` in
|
||||
/// `fields`; [JellyfinClient.listFields] has it).
|
||||
final DateTime? dateCreated;
|
||||
final double? communityRating;
|
||||
final String? officialRating;
|
||||
final int? runTimeTicks;
|
||||
@@ -244,6 +247,7 @@ class JellyfinItem {
|
||||
this.overview,
|
||||
this.productionYear,
|
||||
this.premiereDate,
|
||||
this.dateCreated,
|
||||
this.communityRating,
|
||||
this.officialRating,
|
||||
this.runTimeTicks,
|
||||
@@ -282,6 +286,7 @@ class JellyfinItem {
|
||||
overview: _str(j['Overview']),
|
||||
productionYear: _int(j['ProductionYear']),
|
||||
premiereDate: _date(j['PremiereDate']),
|
||||
dateCreated: _date(j['DateCreated']),
|
||||
communityRating: _double(j['CommunityRating']),
|
||||
officialRating: _str(j['OfficialRating']),
|
||||
runTimeTicks: _int(j['RunTimeTicks']),
|
||||
|
||||
@@ -490,6 +490,8 @@ class MemberView {
|
||||
final bool stale;
|
||||
/// Last `report.jf` of this member; empty when never reported.
|
||||
final Map<String, bool> jf;
|
||||
/// The hosting app's own member (sent the server's host key).
|
||||
final bool host;
|
||||
const MemberView({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -499,6 +501,7 @@ class MemberView {
|
||||
required this.buffered,
|
||||
required this.stale,
|
||||
this.jf = const {},
|
||||
this.host = false,
|
||||
});
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
@@ -510,6 +513,7 @@ class MemberView {
|
||||
'buffered': buffered,
|
||||
'stale': stale,
|
||||
if (jf.isNotEmpty) 'jf': jf,
|
||||
if (host) 'host': true,
|
||||
};
|
||||
|
||||
factory MemberView.fromJson(Map<String, Object?> json) => MemberView(
|
||||
@@ -521,6 +525,7 @@ class MemberView {
|
||||
buffered: finiteDouble(json['buffered'], 'buffered'),
|
||||
stale: (json['stale'] as bool?) ?? false,
|
||||
jf: jfAccessFromJson(json['jf']) ?? const {},
|
||||
host: json['host'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ class EngineMember {
|
||||
double buffered = 0;
|
||||
/// Last reported Jellyfin access by ServerId (`report.jf`).
|
||||
Map<String, bool> jf = const {};
|
||||
/// The hosting app's own member (set by the server from the host key).
|
||||
bool host = false;
|
||||
DateTime lastSeen;
|
||||
EngineMember({
|
||||
required this.id,
|
||||
@@ -91,6 +93,7 @@ class EngineMember {
|
||||
buffered: buffered,
|
||||
stale: stale,
|
||||
jf: jf,
|
||||
host: host,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -164,6 +164,28 @@ void main() {
|
||||
expect(http.last.uri.queryParameters['parentId'], 'a656b907eb3a73532e40e44b968d0225');
|
||||
});
|
||||
|
||||
test('latest ungrouped and recently played: query params, dates', () async {
|
||||
http.on('GET', '/Items/Latest', body: '[{"Id":"e","Type":"Episode",'
|
||||
'"PremiereDate":"2010-01-01T00:00:00.0000000Z","DateCreated":"2026-10-01T12:00:00.0000000Z"}]');
|
||||
final latest = await client.latest('lib', limit: 100, groupItems: false);
|
||||
expect(http.last.uri.queryParameters['groupItems'], 'false');
|
||||
expect(http.last.uri.queryParameters['fields'], contains('DateCreated'));
|
||||
expect(latest.single.premiereDate, DateTime.utc(2010));
|
||||
expect(latest.single.dateCreated, DateTime.utc(2026, 10, 1, 12));
|
||||
|
||||
http.on('GET', '/Items', body: '{"Items":[{"Id":"p","Type":"Episode","SeriesId":"s",'
|
||||
'"UserData":{"Played":true,"LastPlayedDate":"2026-10-07T20:00:00Z"}}],"TotalRecordCount":1}');
|
||||
final played = await client.recentlyPlayed(limit: 50);
|
||||
final q = http.last.uri.queryParameters;
|
||||
expect(q['filters'], 'IsPlayed');
|
||||
expect(q['sortBy'], 'DatePlayed');
|
||||
expect(q['sortOrder'], 'Descending');
|
||||
expect(q['includeItemTypes'], 'Episode,Movie');
|
||||
expect(q['recursive'], 'true');
|
||||
expect(q['limit'], '50');
|
||||
expect(played.single.userData.lastPlayedDate, DateTime.utc(2026, 10, 7, 20));
|
||||
});
|
||||
|
||||
test('library page: query params and paging', () async {
|
||||
http.on('GET', '/Items', fixture: 'series_page');
|
||||
const q = JellyfinItemQuery(
|
||||
|
||||
@@ -538,6 +538,21 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
test('host flag: only the marked member, on the wire as host:true', () {
|
||||
join('a', 'alice');
|
||||
join('b', 'bob');
|
||||
engine.members['a']!.host = true;
|
||||
final members = {for (final m in engine.broadcast().members) m.name: m};
|
||||
expect(members['alice']!.host, isTrue);
|
||||
expect(members['bob']!.host, isFalse);
|
||||
final json = engine.broadcast().toJson()['members'] as List;
|
||||
expect((json.first as Map)['host'], isTrue);
|
||||
expect((json.last as Map).containsKey('host'), isFalse);
|
||||
final back = StateBroadcast.fromJson(
|
||||
Map<String, Object?>.from(jsonDecode(jsonEncode(engine.broadcast().toJson())) as Map));
|
||||
expect([for (final m in back.members) m.host], [true, false]);
|
||||
});
|
||||
|
||||
group('profile + avatars', () {
|
||||
// Real magic bytes: anything that is not PNG/JPEG/WebP/GIF is dropped.
|
||||
final png = base64Encode([0x89, 0x50, 0x4E, 0x47, ...List<int>.generate(60, (i) => i)]);
|
||||
|
||||
@@ -516,7 +516,9 @@ void mountSyncRoute(
|
||||
}
|
||||
memberId = hub.engine.applyJoin(token, session.tokens[token]!.name, hello.user.avatar);
|
||||
}
|
||||
session.tokens[token]!.isHost = hub.isHostKey(hello.hostKey);
|
||||
final isHost = hub.isHostKey(hello.hostKey);
|
||||
session.tokens[token]!.isHost = isHost;
|
||||
hub.engine.members[token]?.host = isHost;
|
||||
final activeToken = token;
|
||||
hub.sockets[activeToken] = ws;
|
||||
session.tokens[activeToken]!.onKick = (reason) async {
|
||||
|
||||
@@ -148,10 +148,27 @@ void main() {
|
||||
await host.ws.close();
|
||||
});
|
||||
|
||||
test('state marks the host member for everyone', () async {
|
||||
await server.stop();
|
||||
server = SyncServer(AppConfig(port: 0, secret: '', name: 't', hostKey: 'hk'));
|
||||
await server.start();
|
||||
final host = await _Peer.join('127.0.0.1', server.boundPort, 'host', hostKey: 'hk');
|
||||
final friend = await _Peer.join('127.0.0.1', server.boundPort, 'friend');
|
||||
final state = StateBroadcast.fromJson(await friend.next('state'));
|
||||
final byName = {for (final m in state.members) m.name: m};
|
||||
expect(byName['host']!.host, isTrue);
|
||||
expect(byName['friend']!.host, isFalse);
|
||||
final raw = server.hub.stateBroadcast().toJson()['members'] as List;
|
||||
expect([for (final m in raw) (m as Map)['host']], containsAll([true, null]));
|
||||
await host.ws.close();
|
||||
await friend.ws.close();
|
||||
});
|
||||
|
||||
test('no hostKey configured: nobody is the host', () async {
|
||||
final a = await _Peer.join('127.0.0.1', server.boundPort, 'a', hostKey: '');
|
||||
final b = await _Peer.join('127.0.0.1', server.boundPort, 'b', hostKey: 'x');
|
||||
expect(server.session.tokens.values.where((t) => t.isHost), isEmpty);
|
||||
expect(server.hub.stateBroadcast().members.where((m) => m.host), isEmpty);
|
||||
await a.ws.close();
|
||||
await b.ws.close();
|
||||
});
|
||||
|
||||
@@ -88,6 +88,12 @@ void main() {
|
||||
const h = MemberView(
|
||||
id: 'm1', name: 'a', avatarHash: 'abc', ready: false, inSet: false, buffered: 0, stale: false);
|
||||
expect(MemberView.fromJson(Map<String, Object?>.from(h.toJson())).avatarHash, 'abc');
|
||||
// host: only on the hosting app's member, absent otherwise.
|
||||
expect(v.toJson().containsKey('host'), isFalse);
|
||||
const host = MemberView(id: 'm2', name: 'h', ready: false, inSet: false, buffered: 0, stale: false, host: true);
|
||||
expect(host.toJson()['host'], isTrue);
|
||||
expect(MemberView.fromJson(Map<String, Object?>.from(host.toJson())).host, isTrue);
|
||||
expect(MemberView.fromJson(Map<String, Object?>.from(v.toJson())).host, isFalse);
|
||||
expect(const ProfileIntent(name: 'b').toJson(), {'t': 'profile', 'name': 'b'});
|
||||
final p = ProfileIntent.fromJson({'t': 'profile', 'avatar': ''});
|
||||
expect(p.name, isNull);
|
||||
|
||||
Reference in new issue
Block a user