diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c280e9ad7..053074aca 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2271,6 +2271,7 @@ }, "processing": "Processing", "seerrDetails": "Seerr details", + "viewTrailer": "View trailer", "watch": "Watch", "watchChannel": "Watch {channel}", "@watchChannel": { diff --git a/lib/providers/seerr/seerr_details_provider.dart b/lib/providers/seerr/seerr_details_provider.dart index d6f392299..1aa3ee0a2 100644 --- a/lib/providers/seerr/seerr_details_provider.dart +++ b/lib/providers/seerr/seerr_details_provider.dart @@ -76,6 +76,7 @@ class SeerrDetails extends _$SeerrDetails { state = state.copyWith( poster: updatedPoster, genres: details.genres ?? [], + relatedVideos: details.relatedVideos ?? const [], voteAverage: details.voteAverage, contentRating: contentRating, releaseDate: details.firstAirDate, @@ -103,6 +104,7 @@ class SeerrDetails extends _$SeerrDetails { state = state.copyWith( poster: updatedPoster, genres: details.genres ?? [], + relatedVideos: details.relatedVideos ?? const [], voteAverage: details.voteAverage, contentRating: contentRating, releaseDate: details.releaseDate, @@ -265,6 +267,7 @@ abstract class SeerrDetailsModel with _$SeerrDetailsModel { SeerrUserModel? currentUser, @Default({}) Map expandedSeasons, @Default({}) Map> episodesCache, + @Default([]) List relatedVideos, SeerrExternalIds? externalIds, SeerrRatingsResponse? ratings, }) = _SeerrDetailsModel; @@ -307,6 +310,55 @@ abstract class SeerrDetailsModel with _$SeerrDetailsModel { return urls; } + SeerrRelatedVideo? get officialTrailer { + if (relatedVideos.isEmpty) return null; + + final trailers = relatedVideos + .where( + (video) => (video.type ?? '').toLowerCase() == 'trailer', + ) + .toList(growable: false); + + for (final trailer in trailers) { + if ((trailer.name ?? '').toLowerCase().contains('official')) { + return trailer; + } + } + + if (trailers.isNotEmpty) { + return trailers.first; + } + + return relatedVideos.first; + } + + String? get officialTrailerUrl { + final trailer = officialTrailer; + final url = trailer?.url; + if (url == null || url.isEmpty) return null; + return url; + } + + bool get hasTrailerAction => (officialTrailerUrl ?? '').isNotEmpty; + + List buildRelatedVideoUrls() { + final urls = []; + + for (var i = 0; i < relatedVideos.length; i++) { + final video = relatedVideos[i]; + final url = video.url; + if (url == null || url.isEmpty) continue; + + final videoName = video.name?.trim() ?? ''; + final videoType = video.type?.trim() ?? ''; + final label = videoName.isNotEmpty ? videoName : (videoType.isNotEmpty ? videoType : 'Video ${i + 1}'); + + urls.add(ExternalUrls(name: label, url: url)); + } + + return urls; + } + bool isRequestedAlready(int seasonNumber) { final status = seasonStatuses[seasonNumber]; return status != null && status.isKnown && status != SeerrMediaStatus.deleted; diff --git a/lib/providers/seerr/seerr_details_provider.freezed.dart b/lib/providers/seerr/seerr_details_provider.freezed.dart index f201e9f4b..c46121190 100644 --- a/lib/providers/seerr/seerr_details_provider.freezed.dart +++ b/lib/providers/seerr/seerr_details_provider.freezed.dart @@ -28,6 +28,7 @@ mixin _$SeerrDetailsModel { SeerrUserModel? get currentUser; Map get expandedSeasons; Map> get episodesCache; + List get relatedVideos; SeerrExternalIds? get externalIds; SeerrRatingsResponse? get ratings; @@ -41,7 +42,7 @@ mixin _$SeerrDetailsModel { @override String toString() { - return 'SeerrDetailsModel(tmdbId: $tmdbId, mediaType: $mediaType, poster: $poster, genres: $genres, voteAverage: $voteAverage, contentRating: $contentRating, releaseDate: $releaseDate, recommended: $recommended, similar: $similar, people: $people, seasonStatuses: $seasonStatuses, currentUser: $currentUser, expandedSeasons: $expandedSeasons, episodesCache: $episodesCache, externalIds: $externalIds, ratings: $ratings)'; + return 'SeerrDetailsModel(tmdbId: $tmdbId, mediaType: $mediaType, poster: $poster, genres: $genres, voteAverage: $voteAverage, contentRating: $contentRating, releaseDate: $releaseDate, recommended: $recommended, similar: $similar, people: $people, seasonStatuses: $seasonStatuses, currentUser: $currentUser, expandedSeasons: $expandedSeasons, episodesCache: $episodesCache, relatedVideos: $relatedVideos, externalIds: $externalIds, ratings: $ratings)'; } } @@ -66,6 +67,7 @@ abstract mixin class $SeerrDetailsModelCopyWith<$Res> { SeerrUserModel? currentUser, Map expandedSeasons, Map> episodesCache, + List relatedVideos, SeerrExternalIds? externalIds, SeerrRatingsResponse? ratings}); @@ -99,6 +101,7 @@ class _$SeerrDetailsModelCopyWithImpl<$Res> Object? currentUser = freezed, Object? expandedSeasons = null, Object? episodesCache = null, + Object? relatedVideos = null, Object? externalIds = freezed, Object? ratings = freezed, }) { @@ -159,6 +162,10 @@ class _$SeerrDetailsModelCopyWithImpl<$Res> ? _self.episodesCache : episodesCache // ignore: cast_nullable_to_non_nullable as Map>, + relatedVideos: null == relatedVideos + ? _self.relatedVideos + : relatedVideos // ignore: cast_nullable_to_non_nullable + as List, externalIds: freezed == externalIds ? _self.externalIds : externalIds // ignore: cast_nullable_to_non_nullable @@ -293,6 +300,7 @@ extension SeerrDetailsModelPatterns on SeerrDetailsModel { SeerrUserModel? currentUser, Map expandedSeasons, Map> episodesCache, + List relatedVideos, SeerrExternalIds? externalIds, SeerrRatingsResponse? ratings)? $default, { @@ -316,6 +324,7 @@ extension SeerrDetailsModelPatterns on SeerrDetailsModel { _that.currentUser, _that.expandedSeasons, _that.episodesCache, + _that.relatedVideos, _that.externalIds, _that.ratings); case _: @@ -353,6 +362,7 @@ extension SeerrDetailsModelPatterns on SeerrDetailsModel { SeerrUserModel? currentUser, Map expandedSeasons, Map> episodesCache, + List relatedVideos, SeerrExternalIds? externalIds, SeerrRatingsResponse? ratings) $default, @@ -375,6 +385,7 @@ extension SeerrDetailsModelPatterns on SeerrDetailsModel { _that.currentUser, _that.expandedSeasons, _that.episodesCache, + _that.relatedVideos, _that.externalIds, _that.ratings); case _: @@ -411,6 +422,7 @@ extension SeerrDetailsModelPatterns on SeerrDetailsModel { SeerrUserModel? currentUser, Map expandedSeasons, Map> episodesCache, + List relatedVideos, SeerrExternalIds? externalIds, SeerrRatingsResponse? ratings)? $default, @@ -433,6 +445,7 @@ extension SeerrDetailsModelPatterns on SeerrDetailsModel { _that.currentUser, _that.expandedSeasons, _that.episodesCache, + _that.relatedVideos, _that.externalIds, _that.ratings); case _: @@ -459,6 +472,7 @@ class _SeerrDetailsModel extends SeerrDetailsModel { this.currentUser, final Map expandedSeasons = const {}, final Map> episodesCache = const {}, + final List relatedVideos = const [], this.externalIds, this.ratings}) : _genres = genres, @@ -468,6 +482,7 @@ class _SeerrDetailsModel extends SeerrDetailsModel { _seasonStatuses = seasonStatuses, _expandedSeasons = expandedSeasons, _episodesCache = episodesCache, + _relatedVideos = relatedVideos, super._(); @override @@ -547,6 +562,15 @@ class _SeerrDetailsModel extends SeerrDetailsModel { return EqualUnmodifiableMapView(_episodesCache); } + final List _relatedVideos; + @override + @JsonKey() + List get relatedVideos { + if (_relatedVideos is EqualUnmodifiableListView) return _relatedVideos; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_relatedVideos); + } + @override final SeerrExternalIds? externalIds; @override @@ -562,7 +586,7 @@ class _SeerrDetailsModel extends SeerrDetailsModel { @override String toString() { - return 'SeerrDetailsModel(tmdbId: $tmdbId, mediaType: $mediaType, poster: $poster, genres: $genres, voteAverage: $voteAverage, contentRating: $contentRating, releaseDate: $releaseDate, recommended: $recommended, similar: $similar, people: $people, seasonStatuses: $seasonStatuses, currentUser: $currentUser, expandedSeasons: $expandedSeasons, episodesCache: $episodesCache, externalIds: $externalIds, ratings: $ratings)'; + return 'SeerrDetailsModel(tmdbId: $tmdbId, mediaType: $mediaType, poster: $poster, genres: $genres, voteAverage: $voteAverage, contentRating: $contentRating, releaseDate: $releaseDate, recommended: $recommended, similar: $similar, people: $people, seasonStatuses: $seasonStatuses, currentUser: $currentUser, expandedSeasons: $expandedSeasons, episodesCache: $episodesCache, relatedVideos: $relatedVideos, externalIds: $externalIds, ratings: $ratings)'; } } @@ -589,6 +613,7 @@ abstract mixin class _$SeerrDetailsModelCopyWith<$Res> SeerrUserModel? currentUser, Map expandedSeasons, Map> episodesCache, + List relatedVideos, SeerrExternalIds? externalIds, SeerrRatingsResponse? ratings}); @@ -623,6 +648,7 @@ class __$SeerrDetailsModelCopyWithImpl<$Res> Object? currentUser = freezed, Object? expandedSeasons = null, Object? episodesCache = null, + Object? relatedVideos = null, Object? externalIds = freezed, Object? ratings = freezed, }) { @@ -683,6 +709,10 @@ class __$SeerrDetailsModelCopyWithImpl<$Res> ? _self._episodesCache : episodesCache // ignore: cast_nullable_to_non_nullable as Map>, + relatedVideos: null == relatedVideos + ? _self._relatedVideos + : relatedVideos // ignore: cast_nullable_to_non_nullable + as List, externalIds: freezed == externalIds ? _self.externalIds : externalIds // ignore: cast_nullable_to_non_nullable diff --git a/lib/providers/seerr/seerr_details_provider.g.dart b/lib/providers/seerr/seerr_details_provider.g.dart index 752461d09..9f6e5792a 100644 --- a/lib/providers/seerr/seerr_details_provider.g.dart +++ b/lib/providers/seerr/seerr_details_provider.g.dart @@ -6,7 +6,7 @@ part of 'seerr_details_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$seerrDetailsHash() => r'5d32aaebe04d6322974626858e92c8b1a3149517'; +String _$seerrDetailsHash() => r'902b94de548fcfbaf9edfbd8f0532955a3e0234d'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/screens/seerr/seerr_details_screen.dart b/lib/screens/seerr/seerr_details_screen.dart index b3b14b247..b43e36763 100644 --- a/lib/screens/seerr/seerr_details_screen.dart +++ b/lib/screens/seerr/seerr_details_screen.dart @@ -64,6 +64,7 @@ class SeerrDetailsScreen extends ConsumerWidget { final itemBaseModel = currentPoster?.itemBaseModel; final externalUrls = state.buildExternalUrls(); + final officialTrailerUrl = state.officialTrailerUrl; final hasKnownStatus = currentPoster?.hasDisplayStatus ?? false; final requests = state.poster?.mediaInfo?.requests ?? []; @@ -95,7 +96,7 @@ class SeerrDetailsScreen extends ConsumerWidget { content: (context, padding) => currentPoster == null ? const SizedBox.shrink() : Padding( - padding: const EdgeInsets.only(bottom: 64), + padding: const EdgeInsets.only(bottom: 64, top: 64), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -229,7 +230,7 @@ class SeerrDetailsScreen extends ConsumerWidget { ); }, ), - centerButtons: hasVisibleRequests + centerButtons: (hasVisibleRequests || state.hasTrailerAction) ? Builder( builder: (context) { return Wrap( @@ -281,7 +282,49 @@ class SeerrDetailsScreen extends ConsumerWidget { ), ), ), - if (currentPoster.mediaInfo != null) + if (state.hasTrailerAction) + FocusButton( + autoFocus: false, + onTap: () async { + if (officialTrailerUrl == null) return; + await launchUrl(context, officialTrailerUrl); + }, + borderRadius: radius, + onFocusChanged: (value) { + if (value) { + context.ensureVisible( + alignment: 1.0, + ); + } + }, + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.tertiaryContainer, + borderRadius: radius, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + Text( + context.localized.viewTrailer, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + color: theme.colorScheme.onTertiaryContainer, + ), + ), + Icon( + IconsaxPlusLinear.video_play, + color: theme.colorScheme.onTertiaryContainer, + ), + ], + ), + ), + ), + ), + if (hasVisibleRequests && currentPoster.mediaInfo != null) FocusButton( autoFocus: false, onTap: () async { diff --git a/lib/seerr/seerr_models.dart b/lib/seerr/seerr_models.dart index 498737807..e0a315e00 100644 --- a/lib/seerr/seerr_models.dart +++ b/lib/seerr/seerr_models.dart @@ -561,6 +561,28 @@ class SeerrCrew { Map toJson() => _$SeerrCrewToJson(this); } +@JsonSerializable(fieldRename: FieldRename.none, includeIfNull: true) +class SeerrRelatedVideo { + final String? url; + final String? key; + final String? name; + final int? size; + final String? type; + final String? site; + + SeerrRelatedVideo({ + this.url, + this.key, + this.name, + this.size, + this.type, + this.site, + }); + + factory SeerrRelatedVideo.fromJson(Map json) => _$SeerrRelatedVideoFromJson(json); + Map toJson() => _$SeerrRelatedVideoToJson(this); +} + @JsonSerializable(fieldRename: FieldRename.none, includeIfNull: true) class SeerrMovieDetails { final int? id; @@ -576,6 +598,7 @@ class SeerrMovieDetails { final int? voteCount; final int? runtime; final List? genres; + final List? relatedVideos; final SeerrMediaInfo? mediaInfo; final SeerrExternalIds? externalIds; final SeerrCredits? credits; @@ -596,6 +619,7 @@ class SeerrMovieDetails { this.voteCount, this.runtime, this.genres, + this.relatedVideos, this.mediaInfo, this.externalIds, this.credits, @@ -625,6 +649,7 @@ class SeerrTvDetails { final int? numberOfEpisodes; final List? genres; final List? seasons; + final List? relatedVideos; final SeerrMediaInfo? mediaInfo; final SeerrExternalIds? externalIds; final List? keywords; @@ -649,6 +674,7 @@ class SeerrTvDetails { this.numberOfEpisodes, this.genres, this.seasons, + this.relatedVideos, this.mediaInfo, this.externalIds, this.keywords, diff --git a/lib/seerr/seerr_models.g.dart b/lib/seerr/seerr_models.g.dart index c563bf8b7..1caacea20 100644 --- a/lib/seerr/seerr_models.g.dart +++ b/lib/seerr/seerr_models.g.dart @@ -133,6 +133,24 @@ Map _$SeerrCrewToJson(SeerrCrew instance) => { 'profilePath': instance.internalProfilePath, }; +SeerrRelatedVideo _$SeerrRelatedVideoFromJson(Map json) => SeerrRelatedVideo( + url: json['url'] as String?, + key: json['key'] as String?, + name: json['name'] as String?, + size: (json['size'] as num?)?.toInt(), + type: json['type'] as String?, + site: json['site'] as String?, + ); + +Map _$SeerrRelatedVideoToJson(SeerrRelatedVideo instance) => { + 'url': instance.url, + 'key': instance.key, + 'name': instance.name, + 'size': instance.size, + 'type': instance.type, + 'site': instance.site, + }; + SeerrMovieDetails _$SeerrMovieDetailsFromJson(Map json) => SeerrMovieDetails( id: (json['id'] as num?)?.toInt(), title: json['title'] as String?, @@ -145,6 +163,9 @@ SeerrMovieDetails _$SeerrMovieDetailsFromJson(Map json) => Seer voteCount: (json['voteCount'] as num?)?.toInt(), runtime: (json['runtime'] as num?)?.toInt(), genres: (json['genres'] as List?)?.map((e) => SeerrGenre.fromJson(e as Map)).toList(), + relatedVideos: (json['relatedVideos'] as List?) + ?.map((e) => SeerrRelatedVideo.fromJson(e as Map)) + .toList(), mediaInfo: json['mediaInfo'] == null ? null : SeerrMediaInfo.fromJson(json['mediaInfo'] as Map), externalIds: json['externalIds'] == null ? null : SeerrExternalIds.fromJson(json['externalIds'] as Map), @@ -167,6 +188,7 @@ Map _$SeerrMovieDetailsToJson(SeerrMovieDetails instance) => json) => SeerrTvDet genres: (json['genres'] as List?)?.map((e) => SeerrGenre.fromJson(e as Map)).toList(), seasons: (json['seasons'] as List?)?.map((e) => SeerrSeason.fromJson(e as Map)).toList(), + relatedVideos: (json['relatedVideos'] as List?) + ?.map((e) => SeerrRelatedVideo.fromJson(e as Map)) + .toList(), mediaInfo: json['mediaInfo'] == null ? null : SeerrMediaInfo.fromJson(json['mediaInfo'] as Map), externalIds: json['externalIds'] == null ? null : SeerrExternalIds.fromJson(json['externalIds'] as Map), @@ -217,6 +242,7 @@ Map _$SeerrTvDetailsToJson(SeerrTvDetails instance) =>