forked from Foxyz/sync_replay_project
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>
This commit is contained in:
1 parent
45f4c1bc84
commit
773a04369e
27 files changed
+5169
-228
No files matched your search
@@ -0,0 +1,90 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user