From 65eeb6fc56726135e39cac3aea99397af93c1f26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hadh=C3=A1zi-Borsos=20M=C3=A1ty=C3=A1s?= Date: Fri, 27 Mar 2026 16:41:47 +0100 Subject: [PATCH 1/6] fix: slideshow banner slider bounds --- lib/screens/shared/media/media_banner.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/screens/shared/media/media_banner.dart b/lib/screens/shared/media/media_banner.dart index 588aed974..bab6f0280 100644 --- a/lib/screens/shared/media/media_banner.dart +++ b/lib/screens/shared/media/media_banner.dart @@ -273,7 +273,7 @@ class _MediaBannerState extends ConsumerState { activeTrackColor: Theme.of(context).colorScheme.surfaceDim, inactiveTrackColor: Theme.of(context).colorScheme.surfaceDim, max: widget.items.length.toDouble() - 1, - onChanged: (value) => setState(() => currentPage = value.toInt()), + onChanged: (value) => setState(() => currentPage = value.round()), ), ) else From ea92f27b22d32b5fc2f2ed9870dd650f63598a3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hadh=C3=A1zi-Borsos=20M=C3=A1ty=C3=A1s?= Date: Wed, 8 Apr 2026 17:16:08 +0200 Subject: [PATCH 2/6] implement basic fallback to trickplay for movies --- lib/models/items/chapters_model.dart | 22 ++++++++++----- lib/models/items/episode_model.dart | 7 ++++- lib/models/items/movie_model.dart | 7 ++++- lib/models/items/overview_model.dart | 13 +++++---- .../items/movies_details_provider.dart | 27 ++++++++++++++----- lib/screens/shared/media/chapter_row.dart | 7 +++++ 6 files changed, 62 insertions(+), 21 deletions(-) diff --git a/lib/models/items/chapters_model.dart b/lib/models/items/chapters_model.dart index 2e1a650cb..73caa5f03 100644 --- a/lib/models/items/chapters_model.dart +++ b/lib/models/items/chapters_model.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; +import 'package:fladder/models/items/trick_play_model.dart'; import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -17,11 +18,13 @@ class Chapter { final String imageUrl; final Uint8List? imageData; final Duration startPosition; + final TrickPlayModel? trickplayFallback; Chapter({ required this.name, required this.imageUrl, this.imageData, required this.startPosition, + this.trickplayFallback, }); ImageProvider get imageProvider { @@ -42,24 +45,29 @@ class Chapter { } } - static List chaptersFromInfo(String itemId, List chapters, Ref ref) { - return chapters - .mapIndexed((index, element) => Chapter( - name: element.name ?? "", - imageUrl: ref.read(imageUtilityProvider).getChapterUrl(itemId, index), - startPosition: Duration(milliseconds: (element.startPositionTicks ?? 0) ~/ 10000))) - .toList(); + static List chaptersFromInfo( + String itemId, List chapters, TrickPlayModel? trickplay, Ref ref) { + return chapters.mapIndexed((index, element) { + final startPosition = Duration(milliseconds: (element.startPositionTicks ?? 0) ~/ 10000); + return Chapter( + name: element.name ?? "", + imageUrl: ref.read(imageUtilityProvider).getChapterUrl(itemId, index), + startPosition: startPosition, + trickplayFallback: trickplay); + }).toList(); } Chapter copyWith({ String? name, String? imageUrl, Duration? startPosition, + TrickPlayModel? trickplayFallback, }) { return Chapter( name: name ?? this.name, imageUrl: imageUrl ?? this.imageUrl, startPosition: startPosition ?? this.startPosition, + trickplayFallback: trickplayFallback ?? this.trickplayFallback, ); } diff --git a/lib/models/items/episode_model.dart b/lib/models/items/episode_model.dart index 6ac17ea46..463273b4d 100644 --- a/lib/models/items/episode_model.dart +++ b/lib/models/items/episode_model.dart @@ -1,5 +1,6 @@ import 'dart:collection'; +import 'package:fladder/models/items/trick_play_model.dart'; import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; @@ -214,6 +215,10 @@ class EpisodeModel extends ItemStreamModel with EpisodeModelMappable { ); } + final trickPlayMap = + (item.trickplay != null && item.trickplay!.isNotEmpty) ? TrickPlayModel.toTrickPlayMap(item.trickplay!) : null; + final singleTrickPlayModel = trickPlayMap?.values.lastOrNull; + return EpisodeModel( seriesName: item.seriesName, name: item.name ?? "", @@ -224,7 +229,7 @@ class EpisodeModel extends ItemStreamModel with EpisodeModelMappable { parentId: item.seriesId, playlistId: item.playlistItemId, dateAired: item.premiereDate, - chapters: Chapter.chaptersFromInfo(item.id ?? "", item.chapters ?? [], ref), + chapters: Chapter.chaptersFromInfo(item.id ?? "", item.chapters ?? [], singleTrickPlayModel, ref), images: ImagesData.fromBaseItem(item, ref), primaryRatio: item.primaryImageAspectRatio, season: item.parentIndexNumber ?? 0, diff --git a/lib/models/items/movie_model.dart b/lib/models/items/movie_model.dart index c0e28efc1..ceafc8c2b 100644 --- a/lib/models/items/movie_model.dart +++ b/lib/models/items/movie_model.dart @@ -1,5 +1,6 @@ // ignore_for_file: public_member_api_docs, sort_constructors_first +import 'package:fladder/models/items/trick_play_model.dart'; import 'package:flutter/material.dart'; import 'package:dart_mappable/dart_mappable.dart'; @@ -126,6 +127,10 @@ class MovieModel extends ItemStreamModel with MovieModelMappable { ); } + final trickPlayMap = + (item.trickplay != null && item.trickplay!.isNotEmpty) ? TrickPlayModel.toTrickPlayMap(item.trickplay!) : null; + final singleTrickPlayModel = trickPlayMap?.values.lastOrNull; + return MovieModel( name: item.name ?? "", id: item.id ?? "", @@ -139,7 +144,7 @@ class MovieModel extends ItemStreamModel with MovieModelMappable { originalTitle: item.originalTitle ?? "", images: ImagesData.fromBaseItem(item, ref), primaryRatio: item.primaryImageAspectRatio, - chapters: Chapter.chaptersFromInfo(item.id ?? "", item.chapters ?? [], ref), + chapters: Chapter.chaptersFromInfo(item.id ?? "", item.chapters ?? [], singleTrickPlayModel, ref), premiereDate: item.premiereDate ?? DateTime.now(), parentImages: ImagesData.fromBaseItemParent(item, ref), canDelete: item.canDelete, diff --git a/lib/models/items/overview_model.dart b/lib/models/items/overview_model.dart index c63a903cd..17b496ab8 100644 --- a/lib/models/items/overview_model.dart +++ b/lib/models/items/overview_model.dart @@ -57,7 +57,10 @@ class OverviewModel with OverviewModelMappable { } factory OverviewModel.fromBaseItemDto(BaseItemDto item, Ref? ref) { - final trickPlayItem = item.trickplay; + final trickPlayMap = + (item.trickplay != null && item.trickplay!.isNotEmpty) ? TrickPlayModel.toTrickPlayMap(item.trickplay!) : null; + final singleTrickPlayModel = trickPlayMap?.values.lastOrNull; + return OverviewModel( runTime: item.runTimeDuration, yearAired: item.productionYear, @@ -68,10 +71,10 @@ class OverviewModel with OverviewModelMappable { communityRating: item.communityRating, tags: item.tags ?? [], dateAdded: item.dateCreated, - trickPlayInfo: - trickPlayItem != null && trickPlayItem.isNotEmpty ? TrickPlayModel.toTrickPlayMap(trickPlayItem) : null, - chapters: - (ref != null && item.id != null) ? Chapter.chaptersFromInfo(item.id ?? "", item.chapters ?? [], ref) : null, + trickPlayInfo: trickPlayMap, + chapters: (ref != null && item.id != null) + ? Chapter.chaptersFromInfo(item.id ?? "", item.chapters ?? [], singleTrickPlayModel, ref) + : null, studios: item.studios?.map((e) => Studio(id: e.id ?? "", name: e.name ?? "")).toList() ?? [], genreItems: item.genreItems?.map((e) => GenreItems(id: e.id ?? "", name: e.name ?? "")).toList() ?? [], externalUrls: ExternalUrls.fromDto(item.externalUrls ?? []), diff --git a/lib/providers/items/movies_details_provider.dart b/lib/providers/items/movies_details_provider.dart index 7a7dfa494..27fcc6d91 100644 --- a/lib/providers/items/movies_details_provider.dart +++ b/lib/providers/items/movies_details_provider.dart @@ -1,6 +1,7 @@ import 'dart:developer'; import 'package:chopper/chopper.dart'; +import 'package:fladder/models/items/chapters_model.dart'; import 'package:logging/logging.dart' as logging; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -80,14 +81,26 @@ class MovieDetails extends _$MovieDetails { } } + List? newChapters = []; + final trickPlay = (await api.getTrickPlay(item: state, ref: ref))?.body; + if (trickPlay != null && trickPlay.images.isNotEmpty) { + newChapters = state?.chapters + .map((chapter) => chapter.copyWith( + trickplayFallback: trickPlay, + )) + .toList(); + } + state = newState.copyWith( - related: related.body, - seerrRelated: seerrRelated, - seerrRecommended: seerrRecommended, - overview: state?.overview.copyWith( - seerrUrl: seerrUrl, - ), - specialFeatures: specialFeatureModel); + related: related.body, + seerrRelated: seerrRelated, + seerrRecommended: seerrRecommended, + overview: state?.overview.copyWith( + seerrUrl: seerrUrl, + ), + specialFeatures: specialFeatureModel, + chapters: newChapters ?? state?.chapters, + ); return null; } catch (e) { return null; diff --git a/lib/screens/shared/media/chapter_row.dart b/lib/screens/shared/media/chapter_row.dart index dabeec849..eee601361 100644 --- a/lib/screens/shared/media/chapter_row.dart +++ b/lib/screens/shared/media/chapter_row.dart @@ -1,3 +1,4 @@ +import 'package:fladder/widgets/shared/trick_play_image.dart'; import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -72,6 +73,12 @@ class ChapterRow extends ConsumerWidget { imageUrl: chapter.imageUrl, fit: BoxFit.cover, placeholder: (context, url) => const Icon(IconsaxPlusBold.image), + errorWidget: (context, url, error) => chapter.trickplayFallback != null + ? TrickPlayImage( + chapter.trickplayFallback!, + position: chapter.startPosition, + ) + : const Icon(IconsaxPlusBold.image), ), ), ), From 6aec72cb489e4d7ad314ffeeef67e0bff0865f93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hadh=C3=A1zi-Borsos=20M=C3=A1ty=C3=A1s?= Date: Fri, 17 Apr 2026 20:22:11 +0200 Subject: [PATCH 3/6] implement trickplay fallback --- lib/models/items/chapters_model.dart | 14 ++++++ lib/screens/shared/media/chapter_row.dart | 59 +++++++++++++++-------- 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/lib/models/items/chapters_model.dart b/lib/models/items/chapters_model.dart index 73caa5f03..48ec0afad 100644 --- a/lib/models/items/chapters_model.dart +++ b/lib/models/items/chapters_model.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:collection/collection.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart' as dto; @@ -90,6 +91,19 @@ class Chapter { String toJson() => json.encode(toMap()); factory Chapter.fromJson(String source) => Chapter.fromMap(json.decode(source)); + + Future isImageValidWithCache({CacheManager? preferredCacheManager}) async { + try { + var cacheManager = preferredCacheManager ?? CustomCacheManager.instance; + FileInfo? fileInfo = await cacheManager.getFileFromCache(imageUrl); + + fileInfo ??= await cacheManager.downloadFile(imageUrl); + + return fileInfo.file.existsSync(); + } catch (e) { + return false; + } + } } extension ChapterExtension on List { diff --git a/lib/screens/shared/media/chapter_row.dart b/lib/screens/shared/media/chapter_row.dart index eee601361..6d2a732bf 100644 --- a/lib/screens/shared/media/chapter_row.dart +++ b/lib/screens/shared/media/chapter_row.dart @@ -1,3 +1,6 @@ +import 'dart:ui'; + +import 'package:fladder/util/custom_cache_manager.dart'; import 'package:fladder/widgets/shared/trick_play_image.dart'; import 'package:flutter/material.dart'; @@ -62,26 +65,44 @@ class ChapterRow extends ConsumerWidget { ); }, child: Container( - decoration: BoxDecoration( - borderRadius: FladderTheme.smallShape.borderRadius, - color: Theme.of(context).colorScheme.surfaceContainer, - ), - foregroundDecoration: FladderTheme.defaultPosterDecoration, - child: AspectRatio( - aspectRatio: 1.75, - child: CachedNetworkImage( - imageUrl: chapter.imageUrl, - fit: BoxFit.cover, - placeholder: (context, url) => const Icon(IconsaxPlusBold.image), - errorWidget: (context, url, error) => chapter.trickplayFallback != null - ? TrickPlayImage( - chapter.trickplayFallback!, - position: chapter.startPosition, - ) - : const Icon(IconsaxPlusBold.image), + decoration: BoxDecoration( + borderRadius: FladderTheme.smallShape.borderRadius, + color: Theme.of(context).colorScheme.surfaceContainer, ), - ), - ), + foregroundDecoration: FladderTheme.defaultPosterDecoration, + child: FutureBuilder( + future: chapter.isImageValidWithCache(), + builder: (context, chImageSnapshot) { + if (chImageSnapshot.connectionState == ConnectionState.waiting) { + return const AspectRatio(aspectRatio: 1.75, child: Icon(IconsaxPlusBold.image)); + } + + if (chImageSnapshot.hasData && chImageSnapshot.data == true) { + return AspectRatio( + aspectRatio: 1.75, + child: CachedNetworkImage( + imageUrl: chapter.imageUrl, + fit: BoxFit.cover, + cacheManager: CustomCacheManager.instance, + )); + } + + if (chapter.trickplayFallback != null) { + var trickplayAspectRatio = chapter.trickplayFallback!.width / chapter.trickplayFallback!.height; + + return AspectRatio( + aspectRatio: trickplayAspectRatio, + child: ImageFiltered( + // tiny bit of blur is better than being pixelated + imageFilter: ImageFilter.blur(sigmaX: 1, sigmaY: 1), + child: TrickPlayImage( + chapter.trickplayFallback!, + position: chapter.startPosition, + ))); + } + + return const Text("No chapter image available"); // TODO Chapter timeline + })), overlays: [ Align( alignment: Alignment.bottomLeft, From a1ed95317c9a22b8bbf6a1421b14e37f19530bb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hadh=C3=A1zi-Borsos=20M=C3=A1ty=C3=A1s?= Date: Fri, 17 Apr 2026 22:04:02 +0200 Subject: [PATCH 4/6] add caching for trickplay images --- lib/widgets/shared/trick_play_image.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/widgets/shared/trick_play_image.dart b/lib/widgets/shared/trick_play_image.dart index 1fbdd3534..fdad5138b 100644 --- a/lib/widgets/shared/trick_play_image.dart +++ b/lib/widgets/shared/trick_play_image.dart @@ -2,11 +2,11 @@ import 'dart:async'; import 'dart:io'; import 'dart:ui' as ui; +import 'package:fladder/util/custom_cache_manager.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:http/http.dart' as http; import 'package:fladder/models/items/trick_play_model.dart'; @@ -81,9 +81,9 @@ class _TrickPlayImageState extends ConsumerState { } Future loadNetworkImage(String url) async { - final http.Response response = await http.get(Uri.parse(url)); - if (response.statusCode == 200) { - final Uint8List bytes = response.bodyBytes; + final file = await CustomCacheManager.instance.getSingleFile(url); + if (file.existsSync()) { + final Uint8List bytes = await file.readAsBytes(); final ui.Codec codec = await ui.instantiateImageCodec(bytes); final ui.FrameInfo frameInfo = await codec.getNextFrame(); if (!_isMounted) return; From 6bddd1966da34ced397ce9a3a4abc3622f7df885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hadh=C3=A1zi-Borsos=20M=C3=A1ty=C3=A1s?= Date: Fri, 17 Apr 2026 22:05:23 +0200 Subject: [PATCH 5/6] finish trickplay fallback logic TODO: Chapter Timeline widget --- lib/models/items/chapters_model.dart | 17 +- lib/models/items/trick_play_model.dart | 18 ++ lib/screens/shared/media/chapter_row.dart | 240 +++++++++++----------- 3 files changed, 150 insertions(+), 125 deletions(-) diff --git a/lib/models/items/chapters_model.dart b/lib/models/items/chapters_model.dart index 48ec0afad..5aecad9d3 100644 --- a/lib/models/items/chapters_model.dart +++ b/lib/models/items/chapters_model.dart @@ -94,12 +94,9 @@ class Chapter { Future isImageValidWithCache({CacheManager? preferredCacheManager}) async { try { - var cacheManager = preferredCacheManager ?? CustomCacheManager.instance; - FileInfo? fileInfo = await cacheManager.getFileFromCache(imageUrl); - - fileInfo ??= await cacheManager.downloadFile(imageUrl); - - return fileInfo.file.existsSync(); + final cacheManager = preferredCacheManager ?? CustomCacheManager.instance; + final file = await cacheManager.getSingleFile(imageUrl); + return file.existsSync(); } catch (e) { return false; } @@ -110,4 +107,12 @@ extension ChapterExtension on List { Chapter? getChapterFromDuration(Duration duration) { return lastWhereOrNull((element) => element.startPosition < duration); } + + Future allChapterImagesValidWithCache({CacheManager? preferredCacheManager}) async { + if (isEmpty) return false; + for (var element in this) { + if (!await element.isImageValidWithCache(preferredCacheManager: preferredCacheManager)) return false; + } + return true; + } } diff --git a/lib/models/items/trick_play_model.dart b/lib/models/items/trick_play_model.dart index 1e47c0c41..39ed45988 100644 --- a/lib/models/items/trick_play_model.dart +++ b/lib/models/items/trick_play_model.dart @@ -1,5 +1,7 @@ import 'dart:ui'; +import 'package:fladder/util/custom_cache_manager.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'trick_play_model.freezed.dart'; @@ -38,6 +40,22 @@ abstract class TrickPlayModel with _$TrickPlayModel { ); } + Future allImagesValidWithCache({CacheManager? preferredCacheManager}) async { + if (images.isEmpty) return false; + try { + final cacheManager = preferredCacheManager ?? CustomCacheManager.instance; + for (var imageUrl in images) { + FileInfo? fileInfo = await cacheManager.getFileFromCache(imageUrl); + + fileInfo ??= await cacheManager.downloadFile(imageUrl); + if (!fileInfo.file.existsSync()) return false; + } + return true; + } catch (e) { + return false; + } + } + static Map toTrickPlayMap(Map map) { Map newMap = {}; final firstMap = (((map.entries.first as MapEntry).value as Map)); diff --git a/lib/screens/shared/media/chapter_row.dart b/lib/screens/shared/media/chapter_row.dart index 6d2a732bf..04fe58aaa 100644 --- a/lib/screens/shared/media/chapter_row.dart +++ b/lib/screens/shared/media/chapter_row.dart @@ -22,128 +22,130 @@ class ChapterRow extends ConsumerWidget { final List chapters; final EdgeInsets contentPadding; final Function(Chapter)? onPressed; - const ChapterRow({required this.contentPadding, this.onPressed, required this.chapters, super.key}); + late final isTrickPlayValid = chapters[0].trickplayFallback?.allImagesValidWithCache() ?? Future.value(false); + late final areChapterImagesValid = chapters.allChapterImagesValidWithCache(); + ChapterRow({required this.contentPadding, this.onPressed, required this.chapters, super.key}); + + Future> get canRenderImages async => + {"chapter": await areChapterImagesValid, "trickplay": await isTrickPlayValid}; @override Widget build(BuildContext context, WidgetRef ref) { - return HorizontalList( - label: context.localized.chapter(chapters.length), - height: AdaptiveLayout.poster(context).size / 1.75, - items: chapters, - itemBuilder: (context, index) { - final chapter = chapters[index]; - List generateActions() { - return [ - ItemActionButton( - action: () => onPressed?.call(chapter), label: Text(context.localized.playFrom(chapter.name))) - ]; - } - - return FocusButton( - onSecondaryTapDown: (details) async { - Offset localPosition = details.globalPosition; - RelativeRect position = - RelativeRect.fromLTRB(localPosition.dx, localPosition.dy, localPosition.dx, localPosition.dy); - await showMenu( - context: context, - position: position, - items: generateActions().popupMenuItems(), - ); - }, - onLongPress: () { - showBottomSheetPill( - context: context, - content: (context, scrollController) { - return ListView( - shrinkWrap: true, - controller: scrollController, - children: [ - ...generateActions().listTileItems(context), - ], - ); - }, - ); - }, - child: Container( - decoration: BoxDecoration( - borderRadius: FladderTheme.smallShape.borderRadius, - color: Theme.of(context).colorScheme.surfaceContainer, - ), - foregroundDecoration: FladderTheme.defaultPosterDecoration, - child: FutureBuilder( - future: chapter.isImageValidWithCache(), - builder: (context, chImageSnapshot) { - if (chImageSnapshot.connectionState == ConnectionState.waiting) { - return const AspectRatio(aspectRatio: 1.75, child: Icon(IconsaxPlusBold.image)); - } - - if (chImageSnapshot.hasData && chImageSnapshot.data == true) { - return AspectRatio( - aspectRatio: 1.75, - child: CachedNetworkImage( - imageUrl: chapter.imageUrl, - fit: BoxFit.cover, - cacheManager: CustomCacheManager.instance, - )); - } - - if (chapter.trickplayFallback != null) { - var trickplayAspectRatio = chapter.trickplayFallback!.width / chapter.trickplayFallback!.height; - - return AspectRatio( - aspectRatio: trickplayAspectRatio, - child: ImageFiltered( - // tiny bit of blur is better than being pixelated - imageFilter: ImageFilter.blur(sigmaX: 1, sigmaY: 1), - child: TrickPlayImage( - chapter.trickplayFallback!, - position: chapter.startPosition, - ))); - } + return FutureBuilder( + future: canRenderImages, + builder: (context, canRender) { + if (canRender.hasData) { + if (canRender.data!["chapter"] == false && canRender.data!["trickplay"] == false) { + // TODO: return ChapterTimeLine(chapters, onPressed, contentPadding) + return Container(padding: contentPadding, child: const Text("This is going to be a timeline view :D")); + } else { + return HorizontalList( + label: context.localized.chapter(chapters.length), + height: AdaptiveLayout.poster(context).size / 1.75, + items: chapters, + itemBuilder: (context, index) { + final chapter = chapters[index]; + List generateActions() { + return [ + ItemActionButton( + action: () => onPressed?.call(chapter), label: Text(context.localized.playFrom(chapter.name))) + ]; + } - return const Text("No chapter image available"); // TODO Chapter timeline - })), - overlays: [ - Align( - alignment: Alignment.bottomLeft, - child: Padding( - padding: const EdgeInsets.all(5), - child: Container( - decoration: BoxDecoration( - borderRadius: FladderTheme.smallShape.borderRadius, - color: Theme.of(context).colorScheme.surfaceContainer.withValues(alpha: 0.75), - ), - child: Padding( - padding: const EdgeInsets.all(5), - child: Text( - "${chapter.name} \n${chapter.startPosition.humanize ?? context.localized.start}", - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold), - ), - ), - ), - ), - ), - ], - focusedOverlays: [ - if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer) - Align( - alignment: Alignment.bottomRight, - child: ExcludeFocus( - child: PopupMenuButton( - tooltip: context.localized.options, - icon: const Icon( - Icons.more_vert, - color: Colors.white, - ), - itemBuilder: (context) => generateActions().popupMenuItems(), - ), - ), - ) - ], - ); - }, - contentPadding: contentPadding, - ); + return FocusButton( + onSecondaryTapDown: (details) async { + Offset localPosition = details.globalPosition; + RelativeRect position = + RelativeRect.fromLTRB(localPosition.dx, localPosition.dy, localPosition.dx, localPosition.dy); + await showMenu( + context: context, + position: position, + items: generateActions().popupMenuItems(), + ); + }, + onLongPress: () { + showBottomSheetPill( + context: context, + content: (context, scrollController) { + return ListView( + shrinkWrap: true, + controller: scrollController, + children: [ + ...generateActions().listTileItems(context), + ], + ); + }, + ); + }, + child: Container( + decoration: BoxDecoration( + borderRadius: FladderTheme.smallShape.borderRadius, + color: Theme.of(context).colorScheme.surfaceContainer, + ), + foregroundDecoration: FladderTheme.defaultPosterDecoration, + child: canRender.data!["chapter"] == true + ? AspectRatio( + aspectRatio: 1.75, + child: CachedNetworkImage( + imageUrl: chapter.imageUrl, + fit: BoxFit.cover, + cacheManager: CustomCacheManager.instance, + )) + : AspectRatio( + aspectRatio: chapter.trickplayFallback!.width / chapter.trickplayFallback!.height, + child: ImageFiltered( + // tiny bit of blur is better than being pixelated + imageFilter: ImageFilter.blur(sigmaX: 1, sigmaY: 1), + child: TrickPlayImage( + chapter.trickplayFallback!, + position: chapter.startPosition, + )))), + overlays: [ + Align( + alignment: Alignment.bottomLeft, + child: Padding( + padding: const EdgeInsets.all(5), + child: Container( + decoration: BoxDecoration( + borderRadius: FladderTheme.smallShape.borderRadius, + color: Theme.of(context).colorScheme.surfaceContainer.withValues(alpha: 0.75), + ), + child: Padding( + padding: const EdgeInsets.all(5), + child: Text( + "${chapter.name} \n${chapter.startPosition.humanize ?? context.localized.start}", + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + ), + ), + ), + ], + focusedOverlays: [ + if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer) + Align( + alignment: Alignment.bottomRight, + child: ExcludeFocus( + child: PopupMenuButton( + tooltip: context.localized.options, + icon: const Icon( + Icons.more_vert, + color: Colors.white, + ), + itemBuilder: (context) => generateActions().popupMenuItems(), + ), + ), + ) + ], + ); + }, + contentPadding: contentPadding, + ); + } + } + // either we're waiting or something has gone wrong + return const AspectRatio(aspectRatio: 1.75, child: Icon(IconsaxPlusBold.image)); + }); } } From 5766b5e68fdee6cd457d46ebc38f8fbe6fae2aad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hadh=C3=A1zi-Borsos=20M=C3=A1ty=C3=A1s?= Date: Sat, 18 Apr 2026 15:45:43 +0200 Subject: [PATCH 6/6] implement fallback for series episodes --- .../items/episode_details_provider.dart | 14 +++++++++++++- .../items/movies_details_provider.dart | 18 ++++++++++-------- lib/screens/shared/media/chapter_row.dart | 3 ++- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/lib/providers/items/episode_details_provider.dart b/lib/providers/items/episode_details_provider.dart index 926e637c8..540f8a0bb 100644 --- a/lib/providers/items/episode_details_provider.dart +++ b/lib/providers/items/episode_details_provider.dart @@ -57,7 +57,19 @@ class EpisodeDetailsProvider extends StateNotifier { if (episodes.body == null) return null; - final episode = (await api.usersUserIdItemsItemIdGet(itemId: item.id)).bodyOrThrow as EpisodeModel; + var episode = (await api.usersUserIdItemsItemIdGet(itemId: item.id)).bodyOrThrow as EpisodeModel; + + if (episode.chapters.any((c) => c.trickplayFallback?.images.isEmpty ?? true)) { + final trickPlay = (await api.getTrickPlay(item: episode, ref: ref))?.body; + if (trickPlay != null && trickPlay.images.isNotEmpty) { + final newChapters = episode.chapters + .map((chapter) => chapter.copyWith( + trickplayFallback: trickPlay, + )) + .toList(); + episode = episode.copyWith(chapters: newChapters); + } + } state = state.copyWith( series: seriesResponse.bodyOrThrow as SeriesModel, diff --git a/lib/providers/items/movies_details_provider.dart b/lib/providers/items/movies_details_provider.dart index 27fcc6d91..3a00e42d6 100644 --- a/lib/providers/items/movies_details_provider.dart +++ b/lib/providers/items/movies_details_provider.dart @@ -81,14 +81,16 @@ class MovieDetails extends _$MovieDetails { } } - List? newChapters = []; - final trickPlay = (await api.getTrickPlay(item: state, ref: ref))?.body; - if (trickPlay != null && trickPlay.images.isNotEmpty) { - newChapters = state?.chapters - .map((chapter) => chapter.copyWith( - trickplayFallback: trickPlay, - )) - .toList(); + List? newChapters; + if (state?.chapters.any((c) => c.trickplayFallback?.images.isEmpty ?? true) ?? true) { + final trickPlay = (await api.getTrickPlay(item: state, ref: ref))?.body; + if (trickPlay != null && trickPlay.images.isNotEmpty) { + newChapters = state!.chapters + .map((chapter) => chapter.copyWith( + trickplayFallback: trickPlay, + )) + .toList(); + } } state = newState.copyWith( diff --git a/lib/screens/shared/media/chapter_row.dart b/lib/screens/shared/media/chapter_row.dart index 04fe58aaa..436a57bf9 100644 --- a/lib/screens/shared/media/chapter_row.dart +++ b/lib/screens/shared/media/chapter_row.dart @@ -22,7 +22,8 @@ class ChapterRow extends ConsumerWidget { final List chapters; final EdgeInsets contentPadding; final Function(Chapter)? onPressed; - late final isTrickPlayValid = chapters[0].trickplayFallback?.allImagesValidWithCache() ?? Future.value(false); + late final isTrickPlayValid = + chapters.firstOrNull?.trickplayFallback?.allImagesValidWithCache() ?? Future.value(false); late final areChapterImagesValid = chapters.allChapterImagesValidWithCache(); ChapterRow({required this.contentPadding, this.onPressed, required this.chapters, super.key});