// Copyright 2020 Ben Hills and the project contributors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:pinepods_mobile/core/annotations.dart'; import 'package:pinepods_mobile/core/extensions.dart'; import 'package:pinepods_mobile/entities/chapter.dart'; import 'package:pinepods_mobile/entities/downloadable.dart'; import 'package:pinepods_mobile/entities/person.dart'; import 'package:pinepods_mobile/entities/transcript.dart'; import 'package:flutter/foundation.dart'; import 'package:html/parser.dart' show parseFragment; import 'package:logging/logging.dart'; /// An object that represents an individual episode of a Podcast. /// /// An Episode can be used in conjunction with a [Downloadable] to /// determine if the Episode is available on the local filesystem. class Episode { final log = Logger('Episode'); /// Database ID int? id; /// A String GUID for the episode. final String guid; /// The GUID for an associated podcast. If an episode has been downloaded /// without subscribing to a podcast this may be null. String? pguid; /// If the episode is currently being downloaded, this contains the unique /// ID supplied by the download manager for the episode. String? downloadTaskId; /// The path to the directory containing the download for this episode; or null. String? filepath; /// The filename of the downloaded episode; or null. String? filename; /// The current downloading state of the episode. DownloadState downloadState = DownloadState.none; /// The name of the podcast the episode is part of. String? podcast; /// The episode title. String? title; /// The episode description. This could be plain text or HTML. String? description; /// More detailed description - optional. String? content; /// External link String? link; /// URL to the episode artwork image. String? imageUrl; /// URL to a thumbnail version of the episode artwork image. String? thumbImageUrl; /// The date the episode was published (if known). DateTime? publicationDate; /// The URL for the episode location. String? contentUrl; /// Author of the episode if known. String? author; /// The season the episode is part of if available. int season; /// The episode number within a season if available. int episode; /// The duration of the episode in milliseconds. This can be populated either from /// the RSS if available, or determined from the MP3 file at stream/download time. int duration; /// Stores the current position within the episode in milliseconds. Used for resuming. int position; /// Stores the progress of the current download progress if available. int? downloadPercentage; /// True if this episode is 'marked as played'. bool played; /// URL pointing to a JSON file containing chapter information if available. String? chaptersUrl; /// List of chapters for the episode if available. List chapters; /// List of transcript URLs for the episode if available. List transcriptUrls; List persons; /// Currently downloaded or in use transcript for the episode.To minimise memory /// use, this is cleared when an episode download is deleted, or a streamed episode stopped. Transcript? transcript; /// Link to a currently stored transcript for this episode. int? transcriptId; /// Date and time episode was last updated and persisted. DateTime? lastUpdated; /// Processed version of episode description. String? _descriptionText; /// Index of the currently playing chapter it available. Transient. int? chapterIndex; /// Current chapter we are listening to if this episode has chapters. Transient. Chapter? currentChapter; /// Set to true if chapter data is currently being loaded. @Transient() bool chaptersLoading = false; @Transient() bool highlight = false; @Transient() bool queued = false; @Transient() bool streaming = true; Episode({ required this.guid, this.pguid, required this.podcast, this.id, this.downloadTaskId, this.filepath, this.filename, this.downloadState = DownloadState.none, this.title, this.description, this.content, this.link, String? imageUrl, String? thumbImageUrl, this.publicationDate, String? contentUrl, this.author, this.season = 0, this.episode = 0, this.duration = 0, this.position = 0, this.downloadPercentage = 0, this.played = false, this.highlight = false, String? chaptersUrl, this.chapters = const [], this.transcriptUrls = const [], this.persons = const [], this.transcriptId = 0, this.lastUpdated, }) : imageUrl = imageUrl?.forceHttps, thumbImageUrl = thumbImageUrl?.forceHttps, contentUrl = contentUrl?.forceHttps, chaptersUrl = chaptersUrl?.forceHttps; Map toMap() { return { 'guid': guid, 'pguid': pguid, 'downloadTaskId': downloadTaskId, 'filepath': filepath, 'filename': filename, 'downloadState': downloadState.index, 'podcast': podcast, 'title': title, 'description': description, 'content': content, 'link': link, 'imageUrl': imageUrl, 'thumbImageUrl': thumbImageUrl, 'publicationDate': publicationDate?.millisecondsSinceEpoch.toString(), 'contentUrl': contentUrl, 'author': author, 'season': season.toString(), 'episode': episode.toString(), 'duration': duration.toString(), 'position': position.toString(), 'downloadPercentage': downloadPercentage.toString(), 'played': played ? 'true' : 'false', 'chaptersUrl': chaptersUrl, 'chapters': (chapters).map((chapter) => chapter.toMap()).toList(growable: false), 'tid': transcriptId ?? 0, 'transcriptUrls': (transcriptUrls).map((tu) => tu.toMap()).toList(growable: false), 'persons': (persons).map((person) => person.toMap()).toList(growable: false), 'lastUpdated': lastUpdated?.millisecondsSinceEpoch.toString() ?? '', }; } static Episode fromMap(int? key, Map episode) { var chapters = []; var transcriptUrls = []; var persons = []; // We need to perform an 'is' on each loop to prevent Dart // from complaining that we have not set the type for chapter. if (episode['chapters'] != null) { for (var chapter in (episode['chapters'] as List)) { if (chapter is Map) { chapters.add(Chapter.fromMap(chapter)); } } } if (episode['transcriptUrls'] != null) { for (var transcriptUrl in (episode['transcriptUrls'] as List)) { if (transcriptUrl is Map) { transcriptUrls.add(TranscriptUrl.fromMap(transcriptUrl)); } } } if (episode['persons'] != null) { for (var person in (episode['persons'] as List)) { if (person is Map) { persons.add(Person.fromMap(person)); } } } return Episode( id: key, guid: episode['guid'] as String, pguid: episode['pguid'] as String?, downloadTaskId: episode['downloadTaskId'] as String?, filepath: episode['filepath'] as String?, filename: episode['filename'] as String?, downloadState: _determineState(episode['downloadState'] as int?), podcast: episode['podcast'] as String?, title: episode['title'] as String?, description: episode['description'] as String?, content: episode['content'] as String?, link: episode['link'] as String?, imageUrl: episode['imageUrl'] as String?, thumbImageUrl: episode['thumbImageUrl'] as String?, publicationDate: episode['publicationDate'] == null || episode['publicationDate'] == 'null' ? DateTime.now() : DateTime.fromMillisecondsSinceEpoch(int.parse(episode['publicationDate'] as String)), contentUrl: episode['contentUrl'] as String?, author: episode['author'] as String?, season: int.parse(episode['season'] as String? ?? '0'), episode: int.parse(episode['episode'] as String? ?? '0'), duration: int.parse(episode['duration'] as String? ?? '0'), position: int.parse(episode['position'] as String? ?? '0'), downloadPercentage: int.parse(episode['downloadPercentage'] as String? ?? '0'), played: episode['played'] == 'true' ? true : false, chaptersUrl: episode['chaptersUrl'] as String?, chapters: chapters, transcriptUrls: transcriptUrls, persons: persons, transcriptId: episode['tid'] == null ? 0 : episode['tid'] as int?, lastUpdated: episode['lastUpdated'] == null || episode['lastUpdated'] == 'null' ? DateTime.now() : DateTime.fromMillisecondsSinceEpoch(int.parse(episode['lastUpdated'] as String)), ); } static DownloadState _determineState(int? index) { switch (index) { case 0: return DownloadState.none; case 1: return DownloadState.queued; case 2: return DownloadState.downloading; case 3: return DownloadState.failed; case 4: return DownloadState.cancelled; case 5: return DownloadState.paused; case 6: return DownloadState.downloaded; } return DownloadState.none; } @override bool operator ==(Object other) { return identical(this, other) || other is Episode && runtimeType == other.runtimeType && guid == other.guid && pguid == other.pguid && downloadTaskId == other.downloadTaskId && filepath == other.filepath && filename == other.filename && downloadState == other.downloadState && podcast == other.podcast && title == other.title && description == other.description && content == other.content && link == other.link && imageUrl == other.imageUrl && thumbImageUrl == other.thumbImageUrl && publicationDate?.millisecondsSinceEpoch == other.publicationDate?.millisecondsSinceEpoch && contentUrl == other.contentUrl && author == other.author && season == other.season && episode == other.episode && duration == other.duration && position == other.position && downloadPercentage == other.downloadPercentage && played == other.played && chaptersUrl == other.chaptersUrl && transcriptId == other.transcriptId && listEquals(persons, other.persons) && listEquals(chapters, other.chapters); } @override int get hashCode => id.hashCode ^ guid.hashCode ^ pguid.hashCode ^ downloadTaskId.hashCode ^ filepath.hashCode ^ filename.hashCode ^ downloadState.hashCode ^ podcast.hashCode ^ title.hashCode ^ description.hashCode ^ content.hashCode ^ link.hashCode ^ imageUrl.hashCode ^ thumbImageUrl.hashCode ^ publicationDate.hashCode ^ contentUrl.hashCode ^ author.hashCode ^ season.hashCode ^ episode.hashCode ^ duration.hashCode ^ position.hashCode ^ downloadPercentage.hashCode ^ played.hashCode ^ chaptersUrl.hashCode ^ chapters.hashCode ^ transcriptId.hashCode ^ lastUpdated.hashCode; @override String toString() { return 'Episode{id: $id, guid: $guid, pguid: $pguid, filepath: $filepath, title: $title, contentUrl: $contentUrl, episode: $episode, duration: $duration, position: $position, downloadPercentage: $downloadPercentage, played: $played, queued: $queued}'; } bool get downloaded => downloadPercentage == 100; Duration get timeRemaining { if (position > 0 && duration > 0) { var currentPosition = Duration(milliseconds: position); var tr = duration - currentPosition.inSeconds; return Duration(seconds: tr); } return const Duration(seconds: 0); } double get percentagePlayed { if (position > 0 && duration > 0) { var pc = (position / (duration * 1000)) * 100; if (pc > 100.0) { pc = 100.0; } return pc; } return 0.0; } String? get descriptionText { if (_descriptionText == null || _descriptionText!.isEmpty) { if (description == null || description!.isEmpty) { _descriptionText = ''; } else { // Replace break tags with space character for readability var formattedDescription = description!.replaceAll(RegExp(r'(
)+'), ' '); _descriptionText = parseFragment(formattedDescription).text; } } return _descriptionText; } bool get hasChapters => (chaptersUrl != null && chaptersUrl!.isNotEmpty) || chapters.isNotEmpty; bool get hasTranscripts => transcriptUrls.isNotEmpty; bool get chaptersAreLoaded => chaptersLoading == false && chapters.isNotEmpty; bool get chaptersAreNotLoaded => chaptersLoading == true && chapters.isEmpty; String? get positionalImageUrl { if (currentChapter != null && currentChapter!.imageUrl != null && currentChapter!.imageUrl!.isNotEmpty) { return currentChapter!.imageUrl; } return imageUrl; } }