added cargo files

This commit is contained in:
2026-03-03 10:57:43 -05:00
parent 478a90e01b
commit 169df46bc2
813 changed files with 227273 additions and 9 deletions

View File

@@ -0,0 +1,152 @@
// 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/entities/search_providers.dart';
class AppSettings {
/// The current theme name.
final String theme;
/// True if episodes are marked as played when deleted.
final bool markDeletedEpisodesAsPlayed;
/// True if downloaded played episodes must be deleted automatically.
final bool deleteDownloadedPlayedEpisodes;
/// True if downloads should be saved to the SD card.
final bool storeDownloadsSDCard;
/// The default playback speed.
final double playbackSpeed;
/// The search provider: itunes or podcastindex.
final String? searchProvider;
/// List of search providers: currently itunes or podcastindex.
final List<SearchProvider> searchProviders;
/// True if the user has confirmed dialog accepting funding links.
final bool externalLinkConsent;
/// If true the main player window will open as soon as an episode starts.
final bool autoOpenNowPlaying;
/// If true the funding link icon will appear (if the podcast supports it).
final bool showFunding;
/// If -1 never; 0 always; otherwise time in minutes.
final int autoUpdateEpisodePeriod;
/// If true, silence in audio playback is trimmed. Currently Android only.
final bool trimSilence;
/// If true, volume is boosted. Currently Android only.
final bool volumeBoost;
/// If 0, list view; else grid view
final int layout;
final String? pinepodsServer;
final String? pinepodsApiKey;
final int? pinepodsUserId;
final String? pinepodsUsername;
final String? pinepodsEmail;
/// Custom order for bottom navigation bar items
final List<String> bottomBarOrder;
AppSettings({
required this.theme,
required this.markDeletedEpisodesAsPlayed,
required this.deleteDownloadedPlayedEpisodes,
required this.storeDownloadsSDCard,
required this.playbackSpeed,
required this.searchProvider,
required this.searchProviders,
required this.externalLinkConsent,
required this.autoOpenNowPlaying,
required this.showFunding,
required this.autoUpdateEpisodePeriod,
required this.trimSilence,
required this.volumeBoost,
required this.layout,
this.pinepodsServer,
this.pinepodsApiKey,
this.pinepodsUserId,
this.pinepodsUsername,
this.pinepodsEmail,
required this.bottomBarOrder,
});
AppSettings.sensibleDefaults()
: theme = 'Dark',
markDeletedEpisodesAsPlayed = false,
deleteDownloadedPlayedEpisodes = false,
storeDownloadsSDCard = false,
playbackSpeed = 1.0,
searchProvider = 'itunes',
searchProviders = <SearchProvider>[],
externalLinkConsent = false,
autoOpenNowPlaying = false,
showFunding = true,
autoUpdateEpisodePeriod = -1,
trimSilence = false,
volumeBoost = false,
layout = 0,
pinepodsServer = null,
pinepodsApiKey = null,
pinepodsUserId = null,
pinepodsUsername = null,
pinepodsEmail = null,
bottomBarOrder = const ['Home', 'Feed', 'Saved', 'Podcasts', 'Downloads', 'History', 'Playlists', 'Search'];
AppSettings copyWith({
String? theme,
bool? markDeletedEpisodesAsPlayed,
bool? deleteDownloadedPlayedEpisodes,
bool? storeDownloadsSDCard,
double? playbackSpeed,
String? searchProvider,
List<SearchProvider>? searchProviders,
bool? externalLinkConsent,
bool? autoOpenNowPlaying,
bool? showFunding,
int? autoUpdateEpisodePeriod,
bool? trimSilence,
bool? volumeBoost,
int? layout,
String? pinepodsServer,
String? pinepodsApiKey,
int? pinepodsUserId,
String? pinepodsUsername,
String? pinepodsEmail,
List<String>? bottomBarOrder,
}) =>
AppSettings(
theme: theme ?? this.theme,
markDeletedEpisodesAsPlayed: markDeletedEpisodesAsPlayed ?? this.markDeletedEpisodesAsPlayed,
deleteDownloadedPlayedEpisodes: deleteDownloadedPlayedEpisodes ?? this.deleteDownloadedPlayedEpisodes,
storeDownloadsSDCard: storeDownloadsSDCard ?? this.storeDownloadsSDCard,
playbackSpeed: playbackSpeed ?? this.playbackSpeed,
searchProvider: searchProvider ?? this.searchProvider,
searchProviders: searchProviders ?? this.searchProviders,
externalLinkConsent: externalLinkConsent ?? this.externalLinkConsent,
autoOpenNowPlaying: autoOpenNowPlaying ?? this.autoOpenNowPlaying,
showFunding: showFunding ?? this.showFunding,
autoUpdateEpisodePeriod: autoUpdateEpisodePeriod ?? this.autoUpdateEpisodePeriod,
trimSilence: trimSilence ?? this.trimSilence,
volumeBoost: volumeBoost ?? this.volumeBoost,
layout: layout ?? this.layout,
pinepodsServer: pinepodsServer ?? this.pinepodsServer,
pinepodsApiKey: pinepodsApiKey ?? this.pinepodsApiKey,
pinepodsUserId: pinepodsUserId ?? this.pinepodsUserId,
pinepodsUsername: pinepodsUsername ?? this.pinepodsUsername,
pinepodsEmail: pinepodsEmail ?? this.pinepodsEmail,
bottomBarOrder: bottomBarOrder ?? this.bottomBarOrder,
);
}

View File

@@ -0,0 +1,71 @@
// 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/extensions.dart';
/// A class that represents an individual chapter within an [Episode].
///
/// Chapters may, or may not, exist for an episode.
///
/// Part of the [podcast namespace](https://github.com/Podcastindex-org/podcast-namespace)
class Chapter {
/// Title of this chapter.
final String title;
/// URL for the chapter image if one is available.
final String? imageUrl;
/// URL of an external link for this chapter if available.
final String? url;
/// Table of contents flag. If this is false the chapter should be treated as
/// meta data only and not be displayed.
final bool toc;
/// The start time of the chapter in seconds.
final double startTime;
/// The optional end time of the chapter in seconds.
final double? endTime;
Chapter({
required this.title,
required String? imageUrl,
required this.startTime,
String? url,
this.toc = true,
this.endTime,
}) : imageUrl = imageUrl?.forceHttps,
url = url?.forceHttps;
Map<String, dynamic> toMap() {
return <String, dynamic>{
'title': title,
'imageUrl': imageUrl,
'url': url,
'toc': toc ? 'true' : 'false',
'startTime': startTime.toString(),
'endTime': endTime.toString(),
};
}
static Chapter fromMap(Map<String, dynamic> chapter) {
return Chapter(
title: chapter['title'] as String,
imageUrl: chapter['imageUrl'] as String?,
url: chapter['url'] as String?,
toc: chapter['toc'] == 'false' ? false : true,
startTime: double.tryParse(chapter['startTime'] as String? ?? '0') ?? 0.0,
endTime: double.tryParse(chapter['endTime'] as String? ?? '0') ?? 0.0,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Chapter && runtimeType == other.runtimeType && title == other.title && startTime == other.startTime;
@override
int get hashCode => title.hashCode ^ startTime.hashCode;
}

View File

@@ -0,0 +1,98 @@
// 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.
enum DownloadState { none, queued, downloading, failed, cancelled, paused, downloaded }
/// A Downloadble is an object that holds information about a podcast episode
/// and its download status.
///
/// Downloadables can be used to determine if a download has been successful and
/// if an episode can be played from the filesystem.
class Downloadable {
/// Database ID
int? id;
/// Unique identifier for the download
final String guid;
/// URL of the file to download
final String url;
/// Destination directory
String directory;
/// Name of file
String filename;
/// Current task ID for the download
String taskId;
/// Current state of the download
DownloadState state;
/// Percentage of MP3 downloaded
int? percentage;
Downloadable({
required this.guid,
required this.url,
required this.directory,
required this.filename,
required this.taskId,
required this.state,
this.percentage,
});
Map<String, dynamic> toMap() {
return <String, dynamic>{
'guid': guid,
'url': url,
'filename': filename,
'directory': directory,
'taskId': taskId,
'state': state.index,
'percentage': percentage.toString(),
};
}
static Downloadable fromMap(Map<String, dynamic> downloadable) {
return Downloadable(
guid: downloadable['guid'] as String,
url: downloadable['url'] as String,
directory: downloadable['directory'] as String,
filename: downloadable['filename'] as String,
taskId: downloadable['taskId'] as String,
state: _determineState(downloadable['state'] as int?),
percentage: int.parse(downloadable['percentage'] 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) =>
identical(this, other) || other is Downloadable && runtimeType == other.runtimeType && guid == other.guid;
@override
int get hashCode => guid.hashCode;
}

View File

@@ -0,0 +1,420 @@
// 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<Chapter> chapters;
/// List of transcript URLs for the episode if available.
List<TranscriptUrl> transcriptUrls;
List<Person> 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 <Chapter>[],
this.transcriptUrls = const <TranscriptUrl>[],
this.persons = const <Person>[],
this.transcriptId = 0,
this.lastUpdated,
}) : imageUrl = imageUrl?.forceHttps,
thumbImageUrl = thumbImageUrl?.forceHttps,
contentUrl = contentUrl?.forceHttps,
chaptersUrl = chaptersUrl?.forceHttps;
Map<String, dynamic> toMap() {
return <String, dynamic>{
'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<String, dynamic> episode) {
var chapters = <Chapter>[];
var transcriptUrls = <TranscriptUrl>[];
var persons = <Person>[];
// 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<String, dynamic>) {
chapters.add(Chapter.fromMap(chapter));
}
}
}
if (episode['transcriptUrls'] != null) {
for (var transcriptUrl in (episode['transcriptUrls'] as List)) {
if (transcriptUrl is Map<String, dynamic>) {
transcriptUrls.add(TranscriptUrl.fromMap(transcriptUrl));
}
}
}
if (episode['persons'] != null) {
for (var person in (episode['persons'] as List)) {
if (person is Map<String, dynamic>) {
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'(<br/?>)+'), ' ');
_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;
}
}

View File

@@ -0,0 +1,39 @@
// 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/entities/podcast.dart';
/// This class is used when loading a [Podcast] feed.
///
/// The key information is contained within the [Podcast] instance, but as the
/// iTunes API also returns large and thumbnail artwork within its search results
/// this class also contains properties to represent those.
class Feed {
/// The podcast to load
final Podcast podcast;
/// The full-size artwork for the podcast.
String? imageUrl;
/// The thumbnail artwork for the podcast,
String? thumbImageUrl;
/// If true the podcast is loaded regardless of if it's currently cached.
bool refresh;
/// If true, will also perform an additional background refresh.
bool backgroundFresh;
/// If true any error can be ignored.
bool silently;
Feed({
required this.podcast,
this.imageUrl,
this.thumbImageUrl,
this.refresh = false,
this.backgroundFresh = false,
this.silently = false,
});
}

View File

@@ -0,0 +1,35 @@
// 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/extensions.dart';
/// part of a [Podcast].
///
/// Part of the [podcast namespace](https://github.com/Podcastindex-org/podcast-namespace)
class Funding {
/// The URL to the funding/donation/information page.
final String url;
/// The label for the link which will be presented to the user.
final String value;
Funding({
required String url,
required this.value,
}) : url = url.forceHttps;
Map<String, dynamic> toMap() {
return <String, dynamic>{
'url': url,
'value': value,
};
}
static Funding fromMap(Map<String, dynamic> chapter) {
return Funding(
url: chapter['url'] as String,
value: chapter['value'] as String,
);
}
}

View File

@@ -0,0 +1,238 @@
// lib/entities/home_data.dart
class HomePodcast {
final int podcastId;
final String podcastName;
final int? podcastIndexId;
final String? artworkUrl;
final String? author;
final String? categories;
final String? description;
final int? episodeCount;
final String? feedUrl;
final String? websiteUrl;
final bool? explicit;
final bool isYoutube;
final int playCount;
final int? totalListenTime;
HomePodcast({
required this.podcastId,
required this.podcastName,
this.podcastIndexId,
this.artworkUrl,
this.author,
this.categories,
this.description,
this.episodeCount,
this.feedUrl,
this.websiteUrl,
this.explicit,
required this.isYoutube,
required this.playCount,
this.totalListenTime,
});
factory HomePodcast.fromJson(Map<String, dynamic> json) {
return HomePodcast(
podcastId: json['podcastid'] ?? 0,
podcastName: json['podcastname'] ?? '',
podcastIndexId: json['podcastindexid'],
artworkUrl: json['artworkurl'],
author: json['author'],
categories: _parseCategories(json['categories']),
description: json['description'],
episodeCount: json['episodecount'],
feedUrl: json['feedurl'],
websiteUrl: json['websiteurl'],
explicit: json['explicit'],
isYoutube: json['is_youtube'] ?? false,
playCount: json['play_count'] ?? 0,
totalListenTime: json['total_listen_time'],
);
}
/// Parse categories from either string or Map format
static String? _parseCategories(dynamic categories) {
if (categories == null) return null;
if (categories is String) {
// Old format - return as is
return categories;
} else if (categories is Map<String, dynamic>) {
// New format - convert map values to comma-separated string
if (categories.isEmpty) return null;
return categories.values.join(', ');
}
return null;
}
}
class HomeEpisode {
final int episodeId;
final int podcastId;
final String episodeTitle;
final String episodeDescription;
final String episodeUrl;
final String episodeArtwork;
final String episodePubDate;
final int episodeDuration;
final bool completed;
final String podcastName;
final bool isYoutube;
final int? listenDuration;
final bool saved;
final bool queued;
final bool downloaded;
HomeEpisode({
required this.episodeId,
required this.podcastId,
required this.episodeTitle,
required this.episodeDescription,
required this.episodeUrl,
required this.episodeArtwork,
required this.episodePubDate,
required this.episodeDuration,
required this.completed,
required this.podcastName,
required this.isYoutube,
this.listenDuration,
this.saved = false,
this.queued = false,
this.downloaded = false,
});
factory HomeEpisode.fromJson(Map<String, dynamic> json) {
return HomeEpisode(
episodeId: json['episodeid'] ?? 0,
podcastId: json['podcastid'] ?? 0,
episodeTitle: json['episodetitle'] ?? '',
episodeDescription: json['episodedescription'] ?? '',
episodeUrl: json['episodeurl'] ?? '',
episodeArtwork: json['episodeartwork'] ?? '',
episodePubDate: json['episodepubdate'] ?? '',
episodeDuration: json['episodeduration'] ?? 0,
completed: json['completed'] ?? false,
podcastName: json['podcastname'] ?? '',
isYoutube: json['is_youtube'] ?? false,
listenDuration: json['listenduration'],
saved: json['saved'] ?? false,
queued: json['queued'] ?? false,
downloaded: json['downloaded'] ?? false,
);
}
/// Format duration in seconds to MM:SS or HH:MM:SS format
String get formattedDuration {
if (episodeDuration <= 0) return '--:--';
final hours = episodeDuration ~/ 3600;
final minutes = (episodeDuration % 3600) ~/ 60;
final seconds = episodeDuration % 60;
if (hours > 0) {
return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
} else {
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}
/// Format listen duration if available
String? get formattedListenDuration {
if (listenDuration == null || listenDuration! <= 0) return null;
final duration = listenDuration!;
final hours = duration ~/ 3600;
final minutes = (duration % 3600) ~/ 60;
final seconds = duration % 60;
if (hours > 0) {
return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
} else {
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}
/// Calculate progress percentage for progress bar
double get progressPercentage {
if (episodeDuration <= 0 || listenDuration == null) return 0.0;
return (listenDuration! / episodeDuration) * 100.0;
}
}
class HomeOverview {
final List<HomeEpisode> recentEpisodes;
final List<HomeEpisode> inProgressEpisodes;
final List<HomePodcast> topPodcasts;
final int savedCount;
final int downloadedCount;
final int queueCount;
HomeOverview({
required this.recentEpisodes,
required this.inProgressEpisodes,
required this.topPodcasts,
required this.savedCount,
required this.downloadedCount,
required this.queueCount,
});
factory HomeOverview.fromJson(Map<String, dynamic> json) {
return HomeOverview(
recentEpisodes: (json['recent_episodes'] as List<dynamic>? ?? [])
.map((e) => HomeEpisode.fromJson(e))
.toList(),
inProgressEpisodes: (json['in_progress_episodes'] as List<dynamic>? ?? [])
.map((e) => HomeEpisode.fromJson(e))
.toList(),
topPodcasts: (json['top_podcasts'] as List<dynamic>? ?? [])
.map((p) => HomePodcast.fromJson(p))
.toList(),
savedCount: json['saved_count'] ?? 0,
downloadedCount: json['downloaded_count'] ?? 0,
queueCount: json['queue_count'] ?? 0,
);
}
}
class Playlist {
final int playlistId;
final String name;
final String? description;
final String iconName;
final int? episodeCount;
Playlist({
required this.playlistId,
required this.name,
this.description,
required this.iconName,
this.episodeCount,
});
factory Playlist.fromJson(Map<String, dynamic> json) {
return Playlist(
playlistId: json['playlist_id'] ?? 0,
name: json['name'] ?? '',
description: json['description'],
iconName: json['icon_name'] ?? 'ph-music-notes',
episodeCount: json['episode_count'],
);
}
}
class PlaylistResponse {
final List<Playlist> playlists;
PlaylistResponse({required this.playlists});
factory PlaylistResponse.fromJson(Map<String, dynamic> json) {
return PlaylistResponse(
playlists: (json['playlists'] as List<dynamic>? ?? [])
.map((p) => Playlist.fromJson(p))
.toList(),
);
}
}

View File

@@ -0,0 +1,81 @@
// Copyright 2020 Ben Hills. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
enum LastState { none, completed, stopped, paused }
/// This class is used to persist information about the currently playing episode to disk.
///
/// This allows the background audio service to persist state (whilst the UI is not visible)
/// and for the episode play and position details to be restored when the UI becomes visible
/// again - either when bringing it to the foreground or upon next start.
class Persistable {
/// The Podcast GUID.
String pguid;
/// The episode ID (provided by the DB layer).
int episodeId;
/// The current position in seconds;
int position;
/// The current playback state.
LastState state;
/// Date & time episode was last updated.
DateTime? lastUpdated;
Persistable({
required this.pguid,
required this.episodeId,
required this.position,
required this.state,
this.lastUpdated,
});
Persistable.empty()
: pguid = '',
episodeId = 0,
position = 0,
state = LastState.none,
lastUpdated = DateTime.now();
Map<String, dynamic> toMap() {
return <String, dynamic>{
'pguid': pguid,
'episodeId': episodeId,
'position': position,
'state': state.toString(),
'lastUpdated': lastUpdated == null ? DateTime.now().millisecondsSinceEpoch : lastUpdated!.millisecondsSinceEpoch,
};
}
static Persistable fromMap(Map<String, dynamic> persistable) {
var stateString = persistable['state'] as String?;
var state = LastState.none;
if (stateString != null) {
switch (stateString) {
case 'LastState.completed':
state = LastState.completed;
break;
case 'LastState.stopped':
state = LastState.stopped;
break;
case 'LastState.paused':
state = LastState.paused;
break;
}
}
var lastUpdated = persistable['lastUpdated'] as int?;
return Persistable(
pguid: persistable['pguid'] as String,
episodeId: persistable['episodeId'] as int,
position: persistable['position'] as int,
state: state,
lastUpdated: lastUpdated == null ? DateTime.now() : DateTime.fromMillisecondsSinceEpoch(lastUpdated),
);
}
}

View File

@@ -0,0 +1,64 @@
// 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/extensions.dart';
/// This class represents a person of interest to the podcast.
///
/// It is primarily intended to identify people like hosts, co-hosts and guests.
class Person {
final String name;
final String role;
final String group;
final String? image;
final String? link;
Person({
required this.name,
this.role = '',
this.group = '',
String? image = '',
String? link = '',
}) : image = image?.forceHttps,
link = link?.forceHttps;
Map<String, dynamic> toMap() {
return <String, dynamic>{
'name': name,
'role': role,
'group': group,
'image': image,
'link': link,
};
}
static Person fromMap(Map<String, dynamic> chapter) {
return Person(
name: chapter['name'] as String? ?? '',
role: chapter['role'] as String? ?? '',
group: chapter['group'] as String? ?? '',
image: chapter['image'] as String? ?? '',
link: chapter['link'] as String? ?? '',
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Person &&
runtimeType == other.runtimeType &&
name == other.name &&
role == other.role &&
group == other.group &&
image == other.image &&
link == other.link;
@override
int get hashCode => name.hashCode ^ role.hashCode ^ group.hashCode ^ image.hashCode ^ link.hashCode;
@override
String toString() {
return 'Person{name: $name, role: $role, group: $group, image: $image, link: $link}';
}
}

View File

@@ -0,0 +1,142 @@
class PinepodsEpisode {
final String podcastName;
final String episodeTitle;
final String episodePubDate;
final String episodeDescription;
final String episodeArtwork;
final String episodeUrl;
final int episodeDuration;
final int? listenDuration;
final int episodeId;
final bool completed;
final bool saved;
final bool queued;
final bool downloaded;
final bool isYoutube;
final int? podcastId;
PinepodsEpisode({
required this.podcastName,
required this.episodeTitle,
required this.episodePubDate,
required this.episodeDescription,
required this.episodeArtwork,
required this.episodeUrl,
required this.episodeDuration,
this.listenDuration,
required this.episodeId,
required this.completed,
required this.saved,
required this.queued,
required this.downloaded,
required this.isYoutube,
this.podcastId,
});
factory PinepodsEpisode.fromJson(Map<String, dynamic> json) {
return PinepodsEpisode(
podcastName: json['Podcastname'] ?? json['podcastname'] ?? '',
episodeTitle: json['Episodetitle'] ?? json['episodetitle'] ?? '',
episodePubDate: json['Episodepubdate'] ?? json['episodepubdate'] ?? '',
episodeDescription: json['Episodedescription'] ?? json['episodedescription'] ?? '',
episodeArtwork: json['Episodeartwork'] ?? json['episodeartwork'] ?? '',
episodeUrl: json['Episodeurl'] ?? json['episodeurl'] ?? '',
episodeDuration: json['Episodeduration'] ?? json['episodeduration'] ?? 0,
listenDuration: json['Listenduration'] ?? json['listenduration'],
episodeId: json['Episodeid'] ?? json['episodeid'] ?? 0,
completed: json['Completed'] ?? json['completed'] ?? false,
saved: json['Saved'] ?? json['saved'] ?? false,
queued: json['Queued'] ?? json['queued'] ?? false,
downloaded: json['Downloaded'] ?? json['downloaded'] ?? false,
isYoutube: json['Is_youtube'] ?? json['is_youtube'] ?? false,
podcastId: json['Podcastid'] ?? json['podcastid'],
);
}
Map<String, dynamic> toJson() {
return {
'podcastname': podcastName,
'episodetitle': episodeTitle,
'episodepubdate': episodePubDate,
'episodedescription': episodeDescription,
'episodeartwork': episodeArtwork,
'episodeurl': episodeUrl,
'episodeduration': episodeDuration,
'listenduration': listenDuration,
'episodeid': episodeId,
'completed': completed,
'saved': saved,
'queued': queued,
'downloaded': downloaded,
'is_youtube': isYoutube,
'podcastid': podcastId,
};
}
/// Format duration from seconds to MM:SS or HH:MM:SS
String get formattedDuration {
if (episodeDuration <= 0) return '0:00';
final hours = episodeDuration ~/ 3600;
final minutes = (episodeDuration % 3600) ~/ 60;
final seconds = episodeDuration % 60;
if (hours > 0) {
return '${hours.toString().padLeft(1, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
} else {
return '${minutes.toString().padLeft(1, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}
/// Get progress percentage (0-100)
double get progressPercentage {
if (episodeDuration <= 0 || listenDuration == null) return 0.0;
return (listenDuration! / episodeDuration * 100).clamp(0.0, 100.0);
}
/// Check if episode has been started (has some listen duration)
bool get isStarted {
return listenDuration != null && listenDuration! > 0;
}
/// Format listen duration from seconds to MM:SS or HH:MM:SS
String get formattedListenDuration {
if (listenDuration == null || listenDuration! <= 0) return '0:00';
final duration = listenDuration!;
final hours = duration ~/ 3600;
final minutes = (duration % 3600) ~/ 60;
final seconds = duration % 60;
if (hours > 0) {
return '${hours.toString().padLeft(1, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
} else {
return '${minutes.toString().padLeft(1, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}
/// Format the publish date to a more readable format
String get formattedPubDate {
try {
final date = DateTime.parse(episodePubDate);
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays == 0) {
return 'Today';
} else if (difference.inDays == 1) {
return 'Yesterday';
} else if (difference.inDays < 7) {
return '${difference.inDays} days ago';
} else if (difference.inDays < 30) {
final weeks = (difference.inDays / 7).floor();
return weeks == 1 ? '1 week ago' : '$weeks weeks ago';
} else {
final months = (difference.inDays / 30).floor();
return months == 1 ? '1 month ago' : '$months months ago';
}
} catch (e) {
return episodePubDate;
}
}
}

View File

@@ -0,0 +1,359 @@
// lib/entities/pinepods_search.dart
class PinepodsSearchResult {
final String? status;
final int? resultCount;
final List<PinepodsPodcast>? feeds;
final List<PinepodsITunesPodcast>? results;
PinepodsSearchResult({
this.status,
this.resultCount,
this.feeds,
this.results,
});
factory PinepodsSearchResult.fromJson(Map<String, dynamic> json) {
return PinepodsSearchResult(
status: json['status'] as String?,
resultCount: json['resultCount'] as int?,
feeds: json['feeds'] != null
? (json['feeds'] as List)
.map((item) => PinepodsPodcast.fromJson(item as Map<String, dynamic>))
.toList()
: null,
results: json['results'] != null
? (json['results'] as List)
.map((item) => PinepodsITunesPodcast.fromJson(item as Map<String, dynamic>))
.toList()
: null,
);
}
Map<String, dynamic> toJson() {
return {
'status': status,
'resultCount': resultCount,
'feeds': feeds?.map((item) => item.toJson()).toList(),
'results': results?.map((item) => item.toJson()).toList(),
};
}
List<UnifiedPinepodsPodcast> getUnifiedPodcasts() {
final List<UnifiedPinepodsPodcast> unified = [];
// Add PodcastIndex results
if (feeds != null) {
unified.addAll(feeds!.map((podcast) => UnifiedPinepodsPodcast.fromPodcast(podcast)));
}
// Add iTunes results
if (results != null) {
unified.addAll(results!.map((podcast) => UnifiedPinepodsPodcast.fromITunesPodcast(podcast)));
}
return unified;
}
}
class PinepodsPodcast {
final int id;
final String title;
final String url;
final String originalUrl;
final String link;
final String description;
final String author;
final String ownerName;
final String image;
final String artwork;
final int lastUpdateTime;
final Map<String, String>? categories;
final bool explicit;
final int episodeCount;
PinepodsPodcast({
required this.id,
required this.title,
required this.url,
required this.originalUrl,
required this.link,
required this.description,
required this.author,
required this.ownerName,
required this.image,
required this.artwork,
required this.lastUpdateTime,
this.categories,
required this.explicit,
required this.episodeCount,
});
factory PinepodsPodcast.fromJson(Map<String, dynamic> json) {
return PinepodsPodcast(
id: json['id'] as int,
title: json['title'] as String? ?? '',
url: json['url'] as String? ?? '',
originalUrl: json['originalUrl'] as String? ?? '',
link: json['link'] as String? ?? '',
description: json['description'] as String? ?? '',
author: json['author'] as String? ?? '',
ownerName: json['ownerName'] as String? ?? '',
image: json['image'] as String? ?? '',
artwork: json['artwork'] as String? ?? '',
lastUpdateTime: json['lastUpdateTime'] as int? ?? 0,
categories: json['categories'] != null
? Map<String, String>.from(json['categories'] as Map)
: null,
explicit: json['explicit'] as bool? ?? false,
episodeCount: json['episodeCount'] as int? ?? 0,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'url': url,
'originalUrl': originalUrl,
'link': link,
'description': description,
'author': author,
'ownerName': ownerName,
'image': image,
'artwork': artwork,
'lastUpdateTime': lastUpdateTime,
'categories': categories,
'explicit': explicit,
'episodeCount': episodeCount,
};
}
}
class PinepodsITunesPodcast {
final String wrapperType;
final String kind;
final int collectionId;
final int trackId;
final String artistName;
final String trackName;
final String collectionViewUrl;
final String feedUrl;
final String artworkUrl100;
final String releaseDate;
final List<String> genres;
final String collectionExplicitness;
final int? trackCount;
PinepodsITunesPodcast({
required this.wrapperType,
required this.kind,
required this.collectionId,
required this.trackId,
required this.artistName,
required this.trackName,
required this.collectionViewUrl,
required this.feedUrl,
required this.artworkUrl100,
required this.releaseDate,
required this.genres,
required this.collectionExplicitness,
this.trackCount,
});
factory PinepodsITunesPodcast.fromJson(Map<String, dynamic> json) {
return PinepodsITunesPodcast(
wrapperType: json['wrapperType'] as String? ?? '',
kind: json['kind'] as String? ?? '',
collectionId: json['collectionId'] as int? ?? 0,
trackId: json['trackId'] as int? ?? 0,
artistName: json['artistName'] as String? ?? '',
trackName: json['trackName'] as String? ?? '',
collectionViewUrl: json['collectionViewUrl'] as String? ?? '',
feedUrl: json['feedUrl'] as String? ?? '',
artworkUrl100: json['artworkUrl100'] as String? ?? '',
releaseDate: json['releaseDate'] as String? ?? '',
genres: json['genres'] != null
? List<String>.from(json['genres'] as List)
: [],
collectionExplicitness: json['collectionExplicitness'] as String? ?? '',
trackCount: json['trackCount'] as int?,
);
}
Map<String, dynamic> toJson() {
return {
'wrapperType': wrapperType,
'kind': kind,
'collectionId': collectionId,
'trackId': trackId,
'artistName': artistName,
'trackName': trackName,
'collectionViewUrl': collectionViewUrl,
'feedUrl': feedUrl,
'artworkUrl100': artworkUrl100,
'releaseDate': releaseDate,
'genres': genres,
'collectionExplicitness': collectionExplicitness,
'trackCount': trackCount,
};
}
}
class UnifiedPinepodsPodcast {
final int id;
final int indexId;
final String title;
final String url;
final String originalUrl;
final String link;
final String description;
final String author;
final String ownerName;
final String image;
final String artwork;
final int lastUpdateTime;
final Map<String, String>? categories;
final bool explicit;
final int episodeCount;
UnifiedPinepodsPodcast({
required this.id,
required this.indexId,
required this.title,
required this.url,
required this.originalUrl,
required this.link,
required this.description,
required this.author,
required this.ownerName,
required this.image,
required this.artwork,
required this.lastUpdateTime,
this.categories,
required this.explicit,
required this.episodeCount,
});
factory UnifiedPinepodsPodcast.fromJson(Map<String, dynamic> json) {
return UnifiedPinepodsPodcast(
id: json['id'] as int? ?? 0,
indexId: json['indexId'] as int? ?? 0,
title: json['title'] as String? ?? '',
url: json['url'] as String? ?? '',
originalUrl: json['originalUrl'] as String? ?? '',
link: json['link'] as String? ?? '',
description: json['description'] as String? ?? '',
author: json['author'] as String? ?? '',
ownerName: json['ownerName'] as String? ?? '',
image: json['image'] as String? ?? '',
artwork: json['artwork'] as String? ?? '',
lastUpdateTime: json['lastUpdateTime'] as int? ?? 0,
categories: json['categories'] != null
? Map<String, String>.from(json['categories'] as Map)
: null,
explicit: json['explicit'] as bool? ?? false,
episodeCount: json['episodeCount'] as int? ?? 0,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'indexId': indexId,
'title': title,
'url': url,
'originalUrl': originalUrl,
'link': link,
'description': description,
'author': author,
'ownerName': ownerName,
'image': image,
'artwork': artwork,
'lastUpdateTime': lastUpdateTime,
'categories': categories,
'explicit': explicit,
'episodeCount': episodeCount,
};
}
factory UnifiedPinepodsPodcast.fromPodcast(PinepodsPodcast podcast) {
return UnifiedPinepodsPodcast(
id: 0, // Internal database ID - will be fetched when needed
indexId: podcast.id, // Podcast index ID
title: podcast.title,
url: podcast.url,
originalUrl: podcast.originalUrl,
author: podcast.author,
ownerName: podcast.ownerName,
description: podcast.description,
image: podcast.image,
link: podcast.link,
artwork: podcast.artwork,
lastUpdateTime: podcast.lastUpdateTime,
categories: podcast.categories,
explicit: podcast.explicit,
episodeCount: podcast.episodeCount,
);
}
factory UnifiedPinepodsPodcast.fromITunesPodcast(PinepodsITunesPodcast podcast) {
// Convert genres list to map
final Map<String, String> genreMap = {};
for (int i = 0; i < podcast.genres.length; i++) {
genreMap[i.toString()] = podcast.genres[i];
}
// Parse release date to timestamp
int timestamp = 0;
try {
final dateTime = DateTime.parse(podcast.releaseDate);
timestamp = dateTime.millisecondsSinceEpoch ~/ 1000;
} catch (e) {
// Default to 0 if parsing fails
}
return UnifiedPinepodsPodcast(
id: podcast.trackId,
indexId: 0,
title: podcast.trackName,
url: podcast.feedUrl,
originalUrl: podcast.feedUrl,
author: podcast.artistName,
ownerName: podcast.artistName,
description: 'Descriptions not provided by iTunes',
image: podcast.artworkUrl100,
link: podcast.collectionViewUrl,
artwork: podcast.artworkUrl100,
lastUpdateTime: timestamp,
categories: genreMap,
explicit: podcast.collectionExplicitness == 'explicit',
episodeCount: podcast.trackCount ?? 0,
);
}
}
enum SearchProvider {
podcastIndex,
itunes,
}
extension SearchProviderExtension on SearchProvider {
String get name {
switch (this) {
case SearchProvider.podcastIndex:
return 'Podcast Index';
case SearchProvider.itunes:
return 'iTunes';
}
}
String get value {
switch (this) {
case SearchProvider.podcastIndex:
return 'podcast_index';
case SearchProvider.itunes:
return 'itunes';
}
}
}

View File

@@ -0,0 +1,248 @@
// 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/extensions.dart';
import 'package:pinepods_mobile/entities/funding.dart';
import 'package:pinepods_mobile/entities/person.dart';
import 'package:podcast_search/podcast_search.dart' as search;
import 'episode.dart';
enum PodcastEpisodeFilter {
none(id: 0),
started(id: 1),
played(id: 2),
notPlayed(id: 3);
const PodcastEpisodeFilter({required this.id});
final int id;
}
enum PodcastEpisodeSort {
none(id: 0),
latestFirst(id: 1),
earliestFirst(id: 2),
alphabeticalAscending(id: 3),
alphabeticalDescending(id: 4);
const PodcastEpisodeSort({required this.id});
final int id;
}
/// A class that represents an instance of a podcast.
///
/// When persisted to disk this represents a podcast that is being followed.
class Podcast {
/// Database ID
int? id;
/// Unique identifier for podcast.
final String? guid;
/// The link to the podcast RSS feed.
final String url;
/// RSS link URL.
final String? link;
/// Podcast title.
final String title;
/// Podcast description. Can be either plain text or HTML.
final String? description;
/// URL to the full size artwork image.
final String? imageUrl;
/// URL for thumbnail version of artwork image. Not contained within
/// the RSS but may be calculated or provided within search results.
final String? thumbImageUrl;
/// Copyright owner of the podcast.
final String? copyright;
/// Zero or more funding links.
final List<Funding>? funding;
PodcastEpisodeFilter filter;
PodcastEpisodeSort sort;
/// Date and time user subscribed to the podcast.
DateTime? subscribedDate;
/// Date and time podcast was last updated/refreshed.
DateTime? _lastUpdated;
/// One or more episodes for this podcast.
List<Episode> episodes;
final List<Person>? persons;
bool newEpisodes;
bool updatedEpisodes = false;
Podcast({
required this.guid,
required String url,
required this.link,
required this.title,
this.id,
this.description,
String? imageUrl,
String? thumbImageUrl,
this.copyright,
this.subscribedDate,
this.funding,
this.filter = PodcastEpisodeFilter.none,
this.sort = PodcastEpisodeSort.none,
this.episodes = const <Episode>[],
this.newEpisodes = false,
this.persons,
DateTime? lastUpdated,
}) : url = url.forceHttps,
imageUrl = imageUrl?.forceHttps,
thumbImageUrl = thumbImageUrl?.forceHttps {
_lastUpdated = lastUpdated;
}
factory Podcast.fromUrl({required String url}) => Podcast(
url: url,
guid: '',
link: '',
title: '',
description: '',
thumbImageUrl: null,
imageUrl: null,
copyright: '',
funding: <Funding>[],
persons: <Person>[],
);
factory Podcast.fromSearchResultItem(search.Item item) => Podcast(
guid: item.guid ?? '',
url: item.feedUrl ?? '',
link: item.feedUrl,
title: item.trackName!,
description: '',
imageUrl: item.bestArtworkUrl ?? item.artworkUrl,
thumbImageUrl: item.thumbnailArtworkUrl,
funding: const <Funding>[],
copyright: item.artistName,
);
Map<String, dynamic> toMap() {
return <String, dynamic>{
'guid': guid,
'title': title,
'copyright': copyright ?? '',
'description': description ?? '',
'url': url,
'link': link ?? '',
'imageUrl': imageUrl ?? '',
'thumbImageUrl': thumbImageUrl ?? '',
'subscribedDate': subscribedDate?.millisecondsSinceEpoch.toString() ?? '',
'filter': filter.id,
'sort': sort.id,
'funding': (funding ?? <Funding>[]).map((funding) => funding.toMap()).toList(growable: false),
'person': (persons ?? <Person>[]).map((persons) => persons.toMap()).toList(growable: false),
'lastUpdated': _lastUpdated?.millisecondsSinceEpoch ?? DateTime.now().millisecondsSinceEpoch,
};
}
static Podcast fromMap(int key, Map<String, dynamic> podcast) {
final sds = podcast['subscribedDate'] as String?;
final lus = podcast['lastUpdated'] as int?;
final funding = <Funding>[];
final persons = <Person>[];
var filter = PodcastEpisodeFilter.none;
var sort = PodcastEpisodeSort.none;
var sd = DateTime.now();
var lastUpdated = DateTime(1971, 1, 1);
if (sds != null && sds.isNotEmpty && int.tryParse(sds) != null) {
sd = DateTime.fromMillisecondsSinceEpoch(int.parse(sds));
}
if (lus != null) {
lastUpdated = DateTime.fromMillisecondsSinceEpoch(lus);
}
if (podcast['funding'] != null) {
for (var chapter in (podcast['funding'] as List)) {
if (chapter is Map<String, dynamic>) {
funding.add(Funding.fromMap(chapter));
}
}
}
if (podcast['persons'] != null) {
for (var person in (podcast['persons'] as List)) {
if (person is Map<String, dynamic>) {
persons.add(Person.fromMap(person));
}
}
}
if (podcast['filter'] != null) {
var filterValue = (podcast['filter'] as int);
filter = switch (filterValue) {
1 => PodcastEpisodeFilter.started,
2 => PodcastEpisodeFilter.played,
3 => PodcastEpisodeFilter.notPlayed,
_ => PodcastEpisodeFilter.none,
};
}
if (podcast['sort'] != null) {
var sortValue = (podcast['sort'] as int);
sort = switch (sortValue) {
1 => PodcastEpisodeSort.latestFirst,
2 => PodcastEpisodeSort.earliestFirst,
3 => PodcastEpisodeSort.alphabeticalAscending,
4 => PodcastEpisodeSort.alphabeticalDescending,
_ => PodcastEpisodeSort.none,
};
}
return Podcast(
id: key,
guid: podcast['guid'] as String,
link: podcast['link'] as String?,
title: podcast['title'] as String,
copyright: podcast['copyright'] as String?,
description: podcast['description'] as String?,
url: podcast['url'] as String,
imageUrl: podcast['imageUrl'] as String?,
thumbImageUrl: podcast['thumbImageUrl'] as String?,
filter: filter,
sort: sort,
funding: funding,
persons: persons,
subscribedDate: sd,
lastUpdated: lastUpdated,
);
}
bool get subscribed => id != null;
DateTime get lastUpdated => _lastUpdated ?? DateTime(1970, 1, 1);
set lastUpdated(DateTime value) {
_lastUpdated = value;
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Podcast && runtimeType == other.runtimeType && guid == other.guid && url == other.url;
@override
int get hashCode => guid.hashCode ^ url.hashCode;
}

View File

@@ -0,0 +1,31 @@
// 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.
/// The current persistable queue.
class Queue {
List<String> guids = <String>[];
Queue({
required this.guids,
});
Map<String, dynamic> toMap() {
return <String, dynamic>{
'q': guids,
};
}
static Queue fromMap(int key, Map<String, dynamic> guids) {
var g = guids['q'] as List<dynamic>?;
var result = <String>[];
if (g != null) {
result = g.map((dynamic e) => e.toString()).toList();
}
return Queue(
guids: result,
);
}
}

View File

@@ -0,0 +1,16 @@
// 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.
/// PinePods can support multiple search providers.
///
/// This class represents a provider.
class SearchProvider {
final String key;
final String name;
SearchProvider({
required this.key,
required this.name,
});
}

View File

@@ -0,0 +1,32 @@
// 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.
enum SleepType {
none,
time,
episode,
}
final class Sleep {
final SleepType type;
final Duration duration;
late DateTime endTime;
Sleep({
required this.type,
this.duration = const Duration(milliseconds: 0),
}) {
endTime = DateTime.now().add(duration);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Sleep && runtimeType == other.runtimeType && type == other.type && duration == other.duration;
@override
int get hashCode => type.hashCode ^ duration.hashCode;
Duration get timeRemaining => endTime.difference(DateTime.now());
}

View File

@@ -0,0 +1,213 @@
// 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/extensions.dart';
import 'package:flutter/foundation.dart';
enum TranscriptFormat {
json,
subrip,
html,
unsupported,
}
/// This class represents a Podcasting 2.0 transcript URL.
///
/// [docs](https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#transcript)
class TranscriptUrl {
final String url;
final TranscriptFormat type;
final String? language;
final String? rel;
final DateTime? lastUpdated;
TranscriptUrl({
required String url,
required this.type,
this.language = '',
this.rel = '',
this.lastUpdated,
}) : url = url.forceHttps;
Map<String, dynamic> toMap() {
var t = 0;
switch (type) {
case TranscriptFormat.subrip:
t = 0;
break;
case TranscriptFormat.json:
t = 1;
break;
case TranscriptFormat.html:
t = 2;
break;
case TranscriptFormat.unsupported:
t = 3;
break;
}
return <String, dynamic>{
'url': url,
'type': t,
'lang': language,
'rel': rel,
'lastUpdated': DateTime.now().millisecondsSinceEpoch,
};
}
static TranscriptUrl fromMap(Map<String, dynamic> transcript) {
var ts = transcript['type'] as int? ?? 2;
var t = TranscriptFormat.unsupported;
switch (ts) {
case 0:
t = TranscriptFormat.subrip;
break;
case 1:
t = TranscriptFormat.json;
break;
case 2:
t = TranscriptFormat.html;
break;
case 3:
t = TranscriptFormat.unsupported;
break;
}
return TranscriptUrl(
url: transcript['url'] as String,
language: transcript['lang'] as String?,
rel: transcript['rel'] as String?,
type: t,
lastUpdated: transcript['lastUpdated'] == null
? DateTime.now()
: DateTime.fromMillisecondsSinceEpoch(transcript['lastUpdated'] as int),
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TranscriptUrl &&
runtimeType == other.runtimeType &&
url == other.url &&
type == other.type &&
language == other.language &&
rel == other.rel;
@override
int get hashCode => url.hashCode ^ type.hashCode ^ language.hashCode ^ rel.hashCode;
}
/// This class represents a Podcasting 2.0 transcript container.
/// [docs](https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#transcript)
class Transcript {
int? id;
String? guid;
final List<Subtitle> subtitles;
DateTime? lastUpdated;
bool filtered;
Transcript({
this.id,
this.guid,
this.subtitles = const <Subtitle>[],
this.filtered = false,
this.lastUpdated,
});
Map<String, dynamic> toMap() {
return <String, dynamic>{
'guid': guid,
'subtitles': (subtitles).map((subtitle) => subtitle.toMap()).toList(growable: false),
'lastUpdated': DateTime.now().millisecondsSinceEpoch,
};
}
static Transcript fromMap(int? key, Map<String, dynamic> transcript) {
var subtitles = <Subtitle>[];
if (transcript['subtitles'] != null) {
for (var subtitle in (transcript['subtitles'] as List)) {
if (subtitle is Map<String, dynamic>) {
subtitles.add(Subtitle.fromMap(subtitle));
}
}
}
return Transcript(
id: key,
guid: transcript['guid'] as String? ?? '',
subtitles: subtitles,
lastUpdated: transcript['lastUpdated'] == null
? DateTime.now()
: DateTime.fromMillisecondsSinceEpoch(transcript['lastUpdated'] as int),
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Transcript &&
runtimeType == other.runtimeType &&
guid == other.guid &&
listEquals(subtitles, other.subtitles);
@override
int get hashCode => guid.hashCode ^ subtitles.hashCode;
bool get transcriptAvailable => (subtitles.isNotEmpty || filtered);
}
/// Represents an individual line within a transcript.
class Subtitle {
final int index;
final Duration start;
Duration? end;
String? data;
String speaker;
Subtitle({
required this.index,
required this.start,
this.end,
this.data,
this.speaker = '',
});
Map<String, dynamic> toMap() {
return <String, dynamic>{
'i': index,
'start': start.inMilliseconds,
'end': end!.inMilliseconds,
'speaker': speaker,
'data': data,
};
}
static Subtitle fromMap(Map<String, dynamic> subtitle) {
return Subtitle(
index: subtitle['i'] as int? ?? 0,
start: Duration(milliseconds: subtitle['start'] as int? ?? 0),
end: Duration(milliseconds: subtitle['end'] as int? ?? 0),
speaker: subtitle['speaker'] as String? ?? '',
data: subtitle['data'] as String? ?? '',
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Subtitle &&
runtimeType == other.runtimeType &&
index == other.index &&
start == other.start &&
end == other.end &&
data == other.data &&
speaker == other.speaker;
@override
int get hashCode => index.hashCode ^ start.hashCode ^ end.hashCode ^ data.hashCode ^ speaker.hashCode;
}

View File

@@ -0,0 +1,91 @@
class UserStats {
final String userCreated;
final int podcastsPlayed;
final int timeListened;
final int podcastsAdded;
final int episodesSaved;
final int episodesDownloaded;
final String gpodderUrl;
final String podSyncType;
UserStats({
required this.userCreated,
required this.podcastsPlayed,
required this.timeListened,
required this.podcastsAdded,
required this.episodesSaved,
required this.episodesDownloaded,
required this.gpodderUrl,
required this.podSyncType,
});
factory UserStats.fromJson(Map<String, dynamic> json) {
return UserStats(
userCreated: json['UserCreated'] ?? '',
podcastsPlayed: json['PodcastsPlayed'] ?? 0,
timeListened: json['TimeListened'] ?? 0,
podcastsAdded: json['PodcastsAdded'] ?? 0,
episodesSaved: json['EpisodesSaved'] ?? 0,
episodesDownloaded: json['EpisodesDownloaded'] ?? 0,
gpodderUrl: json['GpodderUrl'] ?? '',
podSyncType: json['Pod_Sync_Type'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'UserCreated': userCreated,
'PodcastsPlayed': podcastsPlayed,
'TimeListened': timeListened,
'PodcastsAdded': podcastsAdded,
'EpisodesSaved': episodesSaved,
'EpisodesDownloaded': episodesDownloaded,
'GpodderUrl': gpodderUrl,
'Pod_Sync_Type': podSyncType,
};
}
// Format time listened from minutes to human readable
String get formattedTimeListened {
if (timeListened <= 0) return '0 minutes';
final hours = timeListened ~/ 60;
final minutes = timeListened % 60;
if (hours == 0) {
return '$minutes minute${minutes != 1 ? 's' : ''}';
} else if (minutes == 0) {
return '$hours hour${hours != 1 ? 's' : ''}';
} else {
return '$hours hour${hours != 1 ? 's' : ''} $minutes minute${minutes != 1 ? 's' : ''}';
}
}
// Format user created date
String get formattedUserCreated {
try {
final date = DateTime.parse(userCreated);
return '${date.day}/${date.month}/${date.year}';
} catch (e) {
return userCreated;
}
}
// Get sync status description
String get syncStatusDescription {
switch (podSyncType.toLowerCase()) {
case 'none':
return 'Not Syncing';
case 'gpodder':
if (gpodderUrl == 'http://localhost:8042') {
return 'Internal gpodder';
} else {
return 'External gpodder';
}
case 'nextcloud':
return 'Nextcloud';
default:
return 'Unknown sync type';
}
}
}