import 'dart:collection'; import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; import 'package:dart_mappable/dart_mappable.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/jellyfin/enum_models.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart' as dto; import 'package:fladder/models/items/chapters_model.dart'; import 'package:fladder/models/items/images_models.dart'; import 'package:fladder/models/items/item_shared_models.dart'; import 'package:fladder/models/items/item_stream_model.dart'; import 'package:fladder/models/items/media_streams_model.dart'; import 'package:fladder/models/items/overview_model.dart'; import 'package:fladder/models/items/series_model.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/string_extensions.dart'; part 'episode_model.mapper.dart'; enum EpisodeStatus { available, unaired, missing; const EpisodeStatus(); Color get color => switch (this) { EpisodeStatus.available => Colors.lightGreenAccent, EpisodeStatus.unaired => Colors.indigoAccent, EpisodeStatus.missing => Colors.redAccent, }; String label(BuildContext context) => switch (this) { EpisodeStatus.available => context.localized.episodeAvailable, EpisodeStatus.unaired => context.localized.episodeUnaired, EpisodeStatus.missing => context.localized.episodeMissing, }; } @MappableClass() class EpisodeModel extends ItemStreamModel with EpisodeModelMappable { final String? seriesName; final int season; final int episode; final int? episodeEnd; final List chapters; final ItemLocation? location; final DateTime? dateAired; const EpisodeModel({ required this.seriesName, required this.season, required this.episode, required this.episodeEnd, this.chapters = const [], this.location, this.dateAired, required super.name, required super.id, required super.overview, required super.parentId, required super.playlistId, required super.images, required super.childCount, required super.primaryRatio, required super.userData, required super.parentImages, required super.mediaStreams, super.canDelete, super.canDownload, super.jellyType, }); EpisodeStatus get status { return switch (location) { ItemLocation.filesystem => EpisodeStatus.available, ItemLocation.virtual => (dateAired?.isBefore(DateTime.now()) == true) ? EpisodeStatus.missing : EpisodeStatus.unaired, _ => EpisodeStatus.missing }; } @override String? detailedName(BuildContext context) => "${subTextShort(context)} - $name"; @override SeriesModel get parentBaseModel => SeriesModel( originalTitle: '', sortName: '', status: "", name: seriesName ?? "", id: parentId ?? "", playlistId: playlistId, overview: overview, parentId: parentId, images: images, childCount: childCount, primaryRatio: primaryRatio, userData: const UserData(), ); @override String get streamId => parentId ?? ""; @override String get title => seriesName ?? name; @override MediaStreamsModel? get streamModel => mediaStreams; @override ImagesData? get getPosters => parentImages; @override String? get subText => name.isEmpty ? "TBA" : name; @override String? subTextShort(BuildContext context) => seasonEpisodeLabel(context); @override String? label(BuildContext context) => "${subTextShort(context)} - $name"; @override bool get playAble => switch (status) { EpisodeStatus.available => true, _ => false, }; @override String playButtonLabel(BuildContext context) { final string = seasonEpisodeLabel(context).maxLength(); return progress != 0 ? context.localized.resume(string) : context.localized.play(string); } String seasonAnnotation(BuildContext context) => context.localized.season(1)[0]; String episodeAnnotation(BuildContext context) => context.localized.episode(1)[0]; int get episodeCount { if (episodeEnd != null && episodeEnd! > episode) { return episodeEnd! - episode + 1; } return 1; } String get episodeRange { if (episodeEnd != null && episodeEnd! > episode) { return "$episode-${episodeEnd!}"; } return episode.toString(); } String seasonEpisodeLabel(BuildContext context) { return "${seasonAnnotation(context)}$season - ${episodeAnnotation(context)}$episodeRange"; } String seasonEpisodeLabelFull(BuildContext context) { return "${context.localized.season(1)} $season - ${context.localized.episode(episodeCount)} $episodeRange"; } String episodeLabel(BuildContext context) { return "${seasonEpisodeLabel(context)} - $subText"; } String get fullName { return "$episodeRange. $subText"; } @override bool get syncAble => playAble; @override factory EpisodeModel.fromBaseDto(dto.BaseItemDto item, Ref ref) => EpisodeModel( seriesName: item.seriesName, name: item.name ?? "", id: item.id ?? "", childCount: item.childCount, overview: OverviewModel.fromBaseItemDto(item, ref), userData: UserData.fromDto(item.userData), parentId: item.seriesId, playlistId: item.playlistItemId, dateAired: item.premiereDate, chapters: Chapter.chaptersFromInfo(item.id ?? "", item.chapters ?? [], ref), images: ImagesData.fromBaseItem(item, ref), primaryRatio: item.primaryImageAspectRatio, season: item.parentIndexNumber ?? 0, episode: item.indexNumber ?? 0, episodeEnd: item.indexNumberEnd, location: ItemLocation.fromDto(item.locationType), parentImages: ImagesData.fromBaseItemParent(item, ref), canDelete: item.canDelete, canDownload: item.canDownload, mediaStreams: MediaStreamsModel.fromMediaStreamsList(item.mediaSources, ref), jellyType: item.type, ); static List episodesFromDto(List? dto, Ref ref) { return dto?.map((e) => EpisodeModel.fromBaseDto(e, ref)).toList() ?? []; } } extension EpisodeListExtensions on List { Map> get episodesBySeason { final Map> groupedItems = {}; for (int i = 0; i < length; i++) { final int seasonIndex = this[i].season; groupedItems.putIfAbsent(seasonIndex, () => []).add(this[i]); } String addPadding(int value) => value.toString().padLeft(6, '0'); return SplayTreeMap>.from( groupedItems, (a, b) => addPadding(a).compareTo(addPadding(b)), ); } EpisodeModel? get nextUp { final episodes = where((e) => e.season > 0 && e.status == EpisodeStatus.available).toList(); if (episodes.isEmpty) return null; final lastProgressIndex = episodes.lastIndexWhere((e) => e.userData.progress != 0); final lastPlayedIndex = episodes.lastIndexWhere((e) => e.userData.played); final lastWatchedIndex = [lastProgressIndex, lastPlayedIndex].reduce((a, b) => a > b ? a : b); if (lastWatchedIndex >= 0) { final current = episodes[lastWatchedIndex]; if (!current.userData.played && current.userData.progress != 0) { return current; } final nextIndex = lastWatchedIndex + 1; if (nextIndex < episodes.length) { final next = episodes[nextIndex]; if (!next.userData.played && next.userData.progress != 0) { return next; } final nextUnplayed = episodes.sublist(nextIndex).firstWhereOrNull( (e) => e.status == EpisodeStatus.available && !e.userData.played, ); if (nextUnplayed != null) return nextUnplayed; } } return episodes.firstOrNull; } bool get allPlayed { for (var element in this) { if (!element.userData.played) { return false; } } return true; } }