Files
sync_replay_project/apps/desktop/lib/src/jellyfin/format.dart
T
foxyzandClaude Opus 5.5 773a04369e Jellyfin in the app: account linking and library browser
Integrations links several Jellyfin servers (Quick Connect or
password, app-obtained tokens only, status/reconnect/sign out). The
Host tab's Jellyfin source is a full browser: continue watching, next
up, latest per library, library grids with sort/filter/infinite
scroll, series pages with smart resume/next-up, details, grouped
search, inspector pane on wide screens, keyboard navigation.
MediaPicker selection is now a sealed PickedMedia. Hosting a Jellyfin
pick is interim (host's stream URL) until per-viewer playback lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:05:46 +03:00

91 lines
3.2 KiB
Dart

import 'package:sync_relay_core/core.dart';
import '../screens/home/picked_media.dart';
import 'accounts.dart';
/// "1 h 42 min", "24 min", "45 s".
String formatRuntime(Duration d) {
final h = d.inHours, m = d.inMinutes % 60;
if (h > 0) return m == 0 ? '$h h' : '$h h $m min';
if (d.inMinutes > 0) return '${d.inMinutes} min';
return '${d.inSeconds} s';
}
/// Watched share of a resumable item, 0 when there is no resume point.
double progressOf(JellyfinItem item) {
final run = item.runTimeTicks;
final pos = item.userData.playbackPositionTicks;
if (run == null || run <= 0 || pos <= 0) return 0;
return (pos / run).clamp(0.0, 1.0);
}
/// "12 min left" for a resumable item, else null.
String? timeLeft(JellyfinItem item) {
final run = item.runTimeTicks;
if (run == null || !item.userData.isResumable) return null;
final left = ticksToDuration(run - item.userData.playbackPositionTicks);
if (left.inMinutes < 1) return 'almost done';
return '${formatRuntime(left)} left';
}
bool isPlayable(JellyfinItem item) => switch (item.kind) {
JellyfinItemKind.movie || JellyfinItemKind.episode || JellyfinItemKind.video => true,
_ => !item.isFolder && item.mediaType == 'Video',
};
/// Line under a card title: "S2E3 · 12 min left", "2019 · 24 eps", ...
String cardSubtitle(JellyfinItem item, {bool withSeries = false}) {
final parts = <String>[];
switch (item.kind) {
case JellyfinItemKind.episode:
if (withSeries && item.seriesName != null) parts.add(item.seriesName!);
parts.add(item.episodeLabel ?? item.seasonName ?? 'Episode');
case JellyfinItemKind.series:
if (item.productionYear != null) parts.add('${item.productionYear}');
final eps = item.recursiveItemCount, seasons = item.childCount;
if (eps != null && eps > 0) {
parts.add('$eps eps');
} else if (seasons != null && seasons > 0) {
parts.add(seasons == 1 ? '1 season' : '$seasons seasons');
}
case JellyfinItemKind.season:
final n = item.childCount ?? item.recursiveItemCount;
if (n != null) parts.add('$n eps');
default:
if (item.productionYear != null) parts.add('${item.productionYear}');
}
final left = timeLeft(item);
if (left != null) {
parts.add(left);
} else if (isPlayable(item) && item.runTime != null) {
parts.add(formatRuntime(item.runTime!));
}
return parts.join(' · ');
}
/// Title on a card: episodes in rows that mix series show the series name
/// on top and the episode below.
String cardTitle(JellyfinItem item, {bool seriesFirst = false}) =>
seriesFirst && item.kind == JellyfinItemKind.episode && item.seriesName != null
? item.seriesName!
: item.name;
/// The selection for a playable [item] of [account].
JellyfinMedia pickFor(JellyfinAccount account, JellyfinItem item) {
final String subtitle;
if (item.kind == JellyfinItemKind.episode) {
subtitle = [?item.seriesName, ?item.episodeLabel].join(' · ');
} else {
subtitle = item.productionYear?.toString() ?? '';
}
return JellyfinMedia(
serverId: account.serverId,
baseUrl: account.baseUrl,
itemId: item.id,
mediaSourceId: item.mediaSources.length > 1 ? item.mediaSources.first.id : null,
title: item.name,
subtitle: subtitle,
runtime: item.runTime,
);
}