From 96c1ce45a236093cf26a891c99321b0a19c3936d 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 1/3] feat: chapter images fall back to trickplay TODO: Chapter Timeline widget --- lib/models/items/chapters_model.dart | 30 ++- lib/models/items/episode_model.dart | 7 +- lib/models/items/movie_model.dart | 7 +- lib/models/items/overview_model.dart | 13 +- lib/models/items/trick_play_model.dart | 18 ++ .../items/episode_details_provider.dart | 14 +- .../items/movies_details_provider.dart | 29 ++- lib/screens/shared/media/chapter_row.dart | 205 ++++++++++-------- lib/widgets/shared/trick_play_image.dart | 2 +- 9 files changed, 215 insertions(+), 110 deletions(-) diff --git a/lib/models/items/chapters_model.dart b/lib/models/items/chapters_model.dart index 2e1a650cb..8ec1e4a65 100644 --- a/lib/models/items/chapters_model.dart +++ b/lib/models/items/chapters_model.dart @@ -2,10 +2,12 @@ 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'; 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; @@ -17,11 +19,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,12 +46,14 @@ class Chapter { } } - static List chaptersFromInfo(String itemId, List chapters, Ref ref) { + static List chaptersFromInfo( + String itemId, List chapters, TrickPlayModel? trickplay, 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))) + startPosition: Duration(milliseconds: (element.startPositionTicks ?? 0) ~/ 10000), + trickplayFallback: trickplay)) .toList(); } @@ -55,11 +61,13 @@ class Chapter { 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, ); } @@ -82,10 +90,28 @@ class Chapter { String toJson() => json.encode(toMap()); factory Chapter.fromJson(String source) => Chapter.fromMap(json.decode(source)); + + Future isImageValidWithCache({CacheManager? preferredCacheManager}) async { + try { + final cacheManager = preferredCacheManager ?? CustomCacheManager.instance; + final file = await cacheManager.getSingleFile(imageUrl); + return file.existsSync(); + } catch (e) { + return false; + } + } } 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/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/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/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 7a7dfa494..3a00e42d6 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,28 @@ class MovieDetails extends _$MovieDetails { } } + 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( - 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..3e1de8f7c 100644 --- a/lib/screens/shared/media/chapter_row.dart +++ b/lib/screens/shared/media/chapter_row.dart @@ -1,8 +1,10 @@ +import 'package:collection/collection.dart'; +import 'package:fladder/util/custom_cache_manager.dart'; +import 'package:fladder/widgets/shared/trick_play_image.dart'; import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:fladder/models/items/chapters_model.dart'; import 'package:fladder/theme.dart'; @@ -18,104 +20,123 @@ 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.firstOrNull?.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 FutureBuilder( + future: canRenderImages, + builder: (context, canRender) { + if (canRender.hasData && canRender.data!.values.any((i) => i == true)) { + 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), + 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: 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, ); - }, - 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), - ), - ), - ), - 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, - ); + } + // TODO: return ChapterTimeline(chapters, onPressed, contentPadding) + return Container(padding: contentPadding, child: const Text("This is going to be a timeline view :D")); + }); } } diff --git a/lib/widgets/shared/trick_play_image.dart b/lib/widgets/shared/trick_play_image.dart index 1fbdd3534..ea40979fd 100644 --- a/lib/widgets/shared/trick_play_image.dart +++ b/lib/widgets/shared/trick_play_image.dart @@ -122,7 +122,7 @@ class _TrickPlayPainter extends CustomPainter { model.height.toDouble(), ); - Paint paint = Paint(); + Paint paint = Paint()..filterQuality = ui.FilterQuality.medium; Rect dstRect = Rect.fromLTWH(0, 0, size.width, size.height); canvas.drawImageRect(image, srcRect, dstRect, paint); } From e37024962685c25bdbf96249e8576a3415170f12 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 2/3] 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 ea40979fd..dba9975e8 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 51cf167ec5cc813bf13d55322b6146e97f164261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hadh=C3=A1zi-Borsos=20M=C3=A1ty=C3=A1s?= Date: Mon, 28 Sep 2026 21:16:42 +0200 Subject: [PATCH 3/3] feat: add chapter timeline --- lib/screens/shared/media/chapter_row.dart | 8 +- lib/widgets/shared/chapter_timeline.dart | 245 ++++++++++++++++++++++ 2 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 lib/widgets/shared/chapter_timeline.dart diff --git a/lib/screens/shared/media/chapter_row.dart b/lib/screens/shared/media/chapter_row.dart index 3e1de8f7c..17594a4b4 100644 --- a/lib/screens/shared/media/chapter_row.dart +++ b/lib/screens/shared/media/chapter_row.dart @@ -1,5 +1,6 @@ import 'package:collection/collection.dart'; import 'package:fladder/util/custom_cache_manager.dart'; +import 'package:fladder/widgets/shared/chapter_timeline.dart'; import 'package:fladder/widgets/shared/trick_play_image.dart'; import 'package:flutter/material.dart'; @@ -135,8 +136,11 @@ class ChapterRow extends ConsumerWidget { contentPadding: contentPadding, ); } - // TODO: return ChapterTimeline(chapters, onPressed, contentPadding) - return Container(padding: contentPadding, child: const Text("This is going to be a timeline view :D")); + return ChapterTimeline( + chapters: chapters, + onPressed: onPressed, + contentPadding: contentPadding, + ); }); } } diff --git a/lib/widgets/shared/chapter_timeline.dart b/lib/widgets/shared/chapter_timeline.dart new file mode 100644 index 000000000..ab9cd44a2 --- /dev/null +++ b/lib/widgets/shared/chapter_timeline.dart @@ -0,0 +1,245 @@ +import 'package:collection/collection.dart'; +import 'package:fladder/models/items/chapters_model.dart'; +import 'package:fladder/util/humanize_duration.dart'; +import 'package:fladder/util/localization_helper.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +class ChapterTimeline extends StatefulWidget { + final List chapters; + final Function(Chapter)? onPressed; + final EdgeInsets contentPadding; + + const ChapterTimeline({required this.chapters, required this.onPressed, required this.contentPadding, super.key}); + + @override + State createState() => _ChapterTimelineState(); +} + +class _ChapterTimelineState extends State with SingleTickerProviderStateMixin { + int? hoveredIndex; + late final AnimationController fillController; + double fillProgress = 0; + double fillStart = 0; + double fillTarget = 0; + + @override + void initState() { + super.initState(); + fillController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 150), + )..addListener(updateFillProgress); + } + + void updateFillProgress() { + if (!mounted) return; + final curveProgress = Curves.easeInOut.transform(fillController.value); + setState(() => fillProgress = fillStart + (fillTarget - fillStart) * curveProgress); + } + + void animateFillTo(double target) { + fillStart = fillProgress; + fillTarget = target; + fillController.forward(from: 0); + } + + @override + void dispose() { + fillController.dispose(); + super.dispose(); + } + + int chapterAt(double position, double width) { + final slotWidth = width / widget.chapters.length; + return (position / slotWidth).floor().clamp(0, widget.chapters.length - 1); + } + + void updateHoveredIndex(PointerHoverEvent event, double width) { + final index = chapterAt(event.localPosition.dx, width); + if (hoveredIndex != index) { + setState(() => hoveredIndex = index); + animateFillTo(index + 1.0); + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final baseColor = colorScheme.surfaceContainerHigh; + final hoverColor = colorScheme.primary; + final textStyle = Theme.of(context).textTheme.bodyMedium; + + return LayoutBuilder( + builder: (context, constraints) { + final columnWidth = 12 + + widget.chapters + .mapIndexed((i, c) => TextPainter.computeWidth( + text: TextSpan(text: c.name + (i == 0 ? '' : widget.chapters[i - 1].name), style: textStyle), + textDirection: Directionality.of(context), + )) + .max / + 2; + final timelineWidth = columnWidth * widget.chapters.length; + final visibleWidth = constraints.maxWidth.isFinite ? constraints.maxWidth : timelineWidth; + final contentWidth = timelineWidth > visibleWidth ? timelineWidth : visibleWidth; + + return SingleChildScrollView( + padding: widget.contentPadding.copyWith(left: widget.contentPadding.left - columnWidth / 2 + 40), + scrollDirection: Axis.horizontal, + child: SizedBox( + width: contentWidth, + child: MouseRegion( + onHover: (event) => updateHoveredIndex(event, contentWidth), + onExit: (_) { + setState(() => hoveredIndex = null); + animateFillTo(0); + }, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapUp: (details) { + if (widget.onPressed == null) return; + final index = chapterAt(details.localPosition.dx, contentWidth); + widget.onPressed!(widget.chapters[index]); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + spacing: widget.contentPadding.vertical, + children: [ + _ChapterLabels( + chapters: widget.chapters, + columnWidth: columnWidth, + labelBuilder: (chapter) => chapter.name, + color: hoverColor, + textStyle: textStyle, + ), + SizedBox( + width: contentWidth, + height: 32, + child: CustomPaint( + painter: _ChapterTimelinePainter( + chapterCount: widget.chapters.length, + fillProgress: fillProgress, + baseColor: baseColor, + hoverColor: hoverColor, + ), + ), + ), + _ChapterLabels( + chapters: widget.chapters, + columnWidth: columnWidth, + labelBuilder: (chapter) => chapter.startPosition.humanize ?? context.localized.start, + color: hoverColor, + textStyle: textStyle, + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} + +class _ChapterLabels extends StatelessWidget { + final List chapters; + final double columnWidth; + final String Function(Chapter) labelBuilder; + final Color color; + final TextStyle? textStyle; + + const _ChapterLabels({ + required this.chapters, + required this.columnWidth, + required this.labelBuilder, + required this.color, + required this.textStyle, + }); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: chapters + .map( + (chapter) => SizedBox( + width: columnWidth, + height: 24, + child: OverflowBox( + alignment: Alignment.center, + minWidth: 0, + maxWidth: double.infinity, + child: Text( + labelBuilder(chapter), + maxLines: 1, + softWrap: false, + textAlign: TextAlign.center, + style: textStyle?.copyWith(color: color, overflow: TextOverflow.visible), + ), + ), + ), + ) + .toList(), + ); + } +} + +class _ChapterTimelinePainter extends CustomPainter { + final int chapterCount; + final double fillProgress; + final Color baseColor; + final Color hoverColor; + + const _ChapterTimelinePainter({ + required this.chapterCount, + required this.fillProgress, + required this.baseColor, + required this.hoverColor, + }); + + @override + void paint(Canvas canvas, Size size) { + const circleRadius = 8.0; + final centerY = size.height / 2; + final slotWidth = size.width / chapterCount; + final centers = List.generate(chapterCount, (index) => Offset(slotWidth * (index + 0.5), centerY)); + + final linePaint = Paint() + ..strokeCap = StrokeCap.round + ..strokeWidth = 6; + + for (var index = 0; index < centers.length - 1; index++) { + final lineStart = centers[index] + const Offset(circleRadius, 0); + final lineEnd = centers[index + 1] - const Offset(circleRadius, 0); + linePaint.color = baseColor; + canvas.drawLine(lineStart, lineEnd, linePaint); + + final lineProgress = (fillProgress - index - 1).clamp(0.0, 1.0); + if (lineProgress > 0) { + linePaint.color = hoverColor; + canvas.drawLine(lineStart, Offset.lerp(lineStart, lineEnd, lineProgress)!, linePaint); + } + } + + final circlePaint = Paint()..strokeWidth = 6; + + for (var index = 0; index < centers.length; index++) { + final isLeftOfHover = fillProgress != 0 && index <= fillProgress - 1; + circlePaint + ..color = isLeftOfHover ? hoverColor : baseColor + ..style = isLeftOfHover ? PaintingStyle.fill : PaintingStyle.stroke; + canvas.drawCircle(centers[index], circleRadius + (isLeftOfHover ? 3 : 0), circlePaint); + } + } + + @override + bool shouldRepaint(covariant _ChapterTimelinePainter oldDelegate) { + return chapterCount != oldDelegate.chapterCount || + fillProgress != oldDelegate.fillProgress || + baseColor != oldDelegate.baseColor || + hoverColor != oldDelegate.hoverColor; + } +}