import 'dart:async';
import 'dart:ui' as ui;

import 'package:flutter/material.dart';

import 'package:lrc/lrc.dart';
import 'package:super_sliver_list/super_sliver_list.dart';

import 'package:namida/class/track.dart';
import 'package:namida/controller/current_color.dart';
import 'package:namida/controller/lyrics_controller.dart';
import 'package:namida/controller/navigator_controller.dart';
import 'package:namida/controller/player_controller.dart';
import 'package:namida/controller/settings_controller.dart';
import 'package:namida/core/dimensions.dart';
import 'package:namida/core/extensions.dart';
import 'package:namida/core/icon_fonts/broken_icons.dart';
import 'package:namida/core/namida_converter_ext.dart';
import 'package:namida/core/translations/language.dart';
import 'package:namida/core/utils.dart';
import 'package:namida/packages/miniplayer.dart';
import 'package:namida/packages/miniplayer_base.dart';
import 'package:namida/ui/dialogs/set_lrc_dialog.dart';
import 'package:namida/ui/widgets/animated_widgets.dart';
import 'package:namida/ui/widgets/artwork.dart';
import 'package:namida/ui/widgets/custom_widgets.dart';
import 'package:namida/youtube/class/youtube_id.dart';
import 'package:namida/youtube/widgets/yt_thumbnail.dart';

class LyricsLRCParsedView extends StatefulWidget {
  final Widget videoOrImage;
  final bool isFullScreenView;
  final bool canShowToggleFullscreenButton;
  final void Function()? onCloseFullscreenButtonTap;
  final bool allowOverflow;
  final bool useSafeArea;
  final double blurColorMaskOpacity;
  final double? maxHeight;
  final double? maxWidth;
  final double? verticalPadding;
  final Widget? bottomPadding;

  const LyricsLRCParsedView({
    super.key,
    required this.videoOrImage,
    this.isFullScreenView = false,
    this.canShowToggleFullscreenButton = false,
    this.onCloseFullscreenButtonTap,
    this.allowOverflow = true,
    this.useSafeArea = true,
    this.blurColorMaskOpacity = 0.6,
    this.maxHeight,
    this.maxWidth,
    this.verticalPadding,
    this.bottomPadding,
  });

  @override
  State<LyricsLRCParsedView> createState() => LyricsLRCParsedViewState();
}

class LyricsLRCParsedViewState extends State<LyricsLRCParsedView> {
  void toggleFullscreen() {
    if (widget.isFullScreenView) {
      exitFullScreen();
    } else {
      enterFullScreen();
    }
  }

  void enterFullScreen() {
    NamidaNavigator.inst.navigateToRoot(
      LyricsLRCParsedView(
        key: Lyrics.inst.lrcViewKeyFullscreen,
        videoOrImage: widget.videoOrImage,
        isFullScreenView: true,
      ),
      transition: Transition.native,
    );
  }

  void exitFullScreen() {
    NamidaNavigator.inst.popRoot();
  }

  late final ListController _listController;
  late final ScrollController _scrollController;

  late final double _paddingVertical = widget.verticalPadding ?? (widget.isFullScreenView ? 32 * 12.0 : 12 * 12.0);
  int? _currentIndex;
  String _currentLine = '';

  static const _lrcJumpAnimationDuration = Duration(milliseconds: 300);
  static const int _lrcOpacityDurationMS = 500;
  late final bool _updateOpacityForEmptyLines = !widget.isFullScreenView;
  bool _isCurrentLineEmpty = true;

  void _updateIsCurrentLineEmpty(bool empty) {
    if (_isCurrentLineEmpty == empty) return;
    refreshState(() => _isCurrentLineEmpty = empty);
  }

  final _emptyTextRegex = RegExp(r'[^\s]');
  bool _checkIfTextEmpty(String text) {
    final hasAnyChar = _emptyTextRegex.hasMatch(text);
    return !hasAnyChar;
  }

  @override
  void initState() {
    super.initState();
    _listController = ListController();
    _scrollController = NamidaScrollController.create();
    final lrc = Lyrics.inst.currentLyricsLRC.value;
    final txt = Lyrics.inst.currentLyricsText.value;
    fillLists(lrc, txt);
    Player.inst.currentItemDuration.addListener(_itemDurationUpdater);
    Player.inst.nowPlayingPosition.addListener(_playerPositionListener);
  }

  int _itemDurationUpdater() {
    int totalDurMS = Player.inst.currentItemDuration.value?.inMilliseconds ?? 0;
    if (totalDurMS == 0) {
      final current = Player.inst.currentItem.value;
      if (current is Selectable) {
        totalDurMS = current.track.durationMS;
      } else if (current is YoutubeID) {
        totalDurMS = Player.inst.getCurrentVideoDuration.inMilliseconds;
      }
    }
    _currentItemDurationMS.value = totalDurMS;
    return totalDurMS;
  }

  Lrc? currentLRC;

  final _currentItemDurationMS = RxnO<int>();

  void clearLists() {
    highlightTimestampsMap.clear();
    lyrics.clear();
    if (!_isCurrentLineEmpty) {
      _updateIsCurrentLineEmpty(true);
    }
    _latestUpdatedLineInfo.value = null;
    _currentIndex = null;
  }

  void fillLists(Lrc? lrc, LrcText? txt) {
    try {
      _scrollController.jumpTo(0);
    } catch (_) {}
    currentLRC = lrc;
    if (lrc == null) {
      highlightTimestampsMap.clear();
      lyrics.clear();
      final isTextEmpty = txt == null ? true : _checkIfTextEmpty(txt.text);
      _updateIsCurrentLineEmpty(isTextEmpty);
      return;
    } else {
      _updateIsCurrentLineEmpty(_updateOpacityForEmptyLines ? _checkIfTextEmpty(lrc.lyrics.firstOrNull?.lyrics ?? '') : false);
    }
    // -- calculating timestamps multiplier, useful for spedup/slowed/nightcore
    final llength = lrc.length ?? '';
    double cal = 0;
    if (settings.stretchLyricsDuration.value) {
      if (llength != '') {
        final parts = llength.split(RegExp(r'[:.]'));
        try {
          String? hundreds;
          if (parts.length >= 3) {
            // -- converting whatever here to 6-digit microseconds
            hundreds = parts[2];
            var zerosToAdd = 6 - hundreds.length;
            while (zerosToAdd > 0) {
              hundreds = '${hundreds!}0';
              zerosToAdd--;
            }
          }

          final lyricsDuration = Duration(
            minutes: int.parse(parts[0]),
            seconds: int.parse(parts[1]),
            microseconds: hundreds == null ? 0 : int.tryParse(hundreds) ?? 0,
          );
          final totalDurMS = _itemDurationUpdater();
          final totalDurMicro = totalDurMS * 1000;
          cal = totalDurMicro / lyricsDuration.inMicroseconds;
        } catch (_) {}
      }
    }

    highlightTimestampsMap.clear();
    lyrics.clear();

    final uiInfo = lrc.forUiDisplay(
      cal,
      durationDifferenceToInsertEmptyLine: const Duration(seconds: 1),
      extraOffsetDuration: Duration(milliseconds: -settings.visualDelayMS.value),
      romanize: false,
    );

    lyrics = uiInfo.uiLyricsLines;
    highlightTimestampsMap = uiInfo.highlightTimestampsMap;

    _updateHighlightedLine(Player.inst.nowPlayingPosition.value, jump: true);
  }

  void _playerPositionListener() {
    final position = Player.inst.nowPlayingPosition.value;
    _updateHighlightedLine(position);
  }

  void _updateHighlightedLine(int durMS, {bool force = false, bool forceAnimate = false, bool jump = false}) {
    final lrcDur = lyrics.lastWhereEff((e) => e.timestamp <= Duration(milliseconds: durMS + 5) && !e.isBGLyrics) /* ?? lyrics.firstOrNull */;
    final newLineDuration = lrcDur?.timestamp;

    // -- prefer index checks, cuz duration is used in ui directly to highlight
    // -- and index can be used to force reset (to force scroll to current line) etc
    // if (!force && _latestUpdatedLineInfo.value?.$1 == newLineDuration) return;

    int? newIndexPre = newLineDuration == null ? null : highlightTimestampsMap[newLineDuration]?.firstOrNull;
    if (newIndexPre == null) return;

    if (newIndexPre + 1 == lyrics.length) {
      final alreadyHighlightingLastLine = _currentIndex == newIndexPre;
      if (alreadyHighlightingLastLine) {
        return; // overscrolling
      } else {
        newIndexPre = lyrics.length - 1; // go to last line
      }
    }
    if (newIndexPre < 0) newIndexPre = 0;

    if (!force && _currentIndex == newIndexPre) return;

    WidgetsBinding.instance.addPostFrameCallback((_) {
      int newIndex = newIndexPre!;
      _latestUpdatedLineInfo.value = (lrcDur?.timestamp, newIndex);

      if ((_canAnimateScroll.value || forceAnimate) && _listController.isAttached) {
        _currentIndex = newIndex;
        jump
            ? _listController.jumpToItem(
                scrollController: _scrollController,
                alignment: 0.4,
                index: newIndex,
              )
            : _listController.animateToItem(
                scrollController: _scrollController,
                alignment: 0.4,
                index: newIndex,
                duration: (d) => _lrcJumpAnimationDuration,
                curve: (d) => Curves.easeOut,
              );
        try {
          _currentLine = lyrics[newIndex].lyrics;
        } catch (_) {
          _currentLine = '';
        }
        if (_updateOpacityForEmptyLines) {
          final line = _currentLine;
          late final emptyLine = _checkIfTextEmpty(_currentLine);
          if (_isCurrentLineEmpty && !emptyLine) {
            _updateIsCurrentLineEmpty(emptyLine);
          } else {
            if (_isCurrentLineEmpty || emptyLine) {
              final timeToWaitMS = _isCurrentLineEmpty ? 200 : 1200; // execute faster if current one empty (ie: cuz next most likely not empty)
              bool butIsItWorth = true;
              if (newIndex < lyrics.length - 1) {
                try {
                  final diff = lyrics[newIndex + 1].timestamp - lyrics[newIndex].timestamp; // (newIndex) is the empty line
                  if (diff.abs() < const Duration(milliseconds: _lrcOpacityDurationMS * 2 + 1200)) butIsItWorth = false;
                } catch (_) {}
              } else {
                butIsItWorth = true; // last line has nothing next so its always worth ^^
              }

              if (butIsItWorth) {
                Timer(Duration(milliseconds: timeToWaitMS), () {
                  if (line == _currentLine && _isCurrentLineEmpty != emptyLine) {
                    _updateIsCurrentLineEmpty(emptyLine);
                  }
                });
              }
            }
          }
        }
      }
    });
  }

  Timer? _scrollTimer;
  final _canAnimateScroll = true.obs;

  final _latestUpdatedLineInfo = Rxn<(Duration?, int?)>();

  var lyrics = <LrcLine>[];
  var highlightTimestampsMap = <Duration, List<int>>{}; // timestamp: [index]

  late double _previousFontMultiplier = widget.isFullScreenView ? settings.fontScaleLRCFull : settings.fontScaleLRC;
  late double _fontMultiplier = widget.isFullScreenView ? settings.fontScaleLRCFull : settings.fontScaleLRC;

  @override
  void dispose() {
    Player.inst.currentItemDuration.removeListener(_itemDurationUpdater);
    Player.inst.nowPlayingPosition.removeListener(_playerPositionListener);

    _latestUpdatedLineInfo.close();
    _currentItemDurationMS.close();
    _listController.dispose();
    _scrollController.dispose();
    _canAnimateScroll.close();
    super.dispose();
  }

  void _onPointerDown(dynamic _) {
    _scrollTimer?.cancel();
    _scrollTimer = null;
    if (currentLRC != null) {
      // -- only if synced
      _canAnimateScroll.value = false;
    }
    if (_isCurrentLineEmpty) {
      _updateIsCurrentLineEmpty(false);
    }
  }

  void _onPointerUp(dynamic _) {
    _scrollTimer?.cancel();
    _scrollTimer = Timer(const Duration(seconds: 3), () {
      if (Player.inst.playWhenReady.value) {
        _canAnimateScroll.value = true;
        _updateHighlightedLine(Player.inst.nowPlayingPosition.value, forceAnimate: true, force: true);
      }
      if (_updateOpacityForEmptyLines && currentLRC != null && _checkIfTextEmpty(_currentLine)) {
        _updateIsCurrentLineEmpty(true);
      }
    });
  }

  // -- mouse scroll
  void _onPointerSignal(dynamic _) {
    _onPointerDown(null);
    _onPointerUp(null);
  }

  @override
  Widget build(BuildContext context) {
    final theme = context.theme;
    final textTheme = theme.textTheme;
    final fullscreen = widget.isFullScreenView;
    const maxLyricsWidth = 864.0;
    final alignAtStart = fullscreen && context.width < maxLyricsWidth; // -- align at center if screen got too wide
    final initialFontSize = fullscreen ? 26.0 : 15.0;
    final normalTextStyle = textTheme.displayMedium!.copyWith(fontSize: _fontMultiplier * initialFontSize);
    final plainLyricsTextStyle = normalTextStyle.copyWith(height: 1.8);
    final fullscreenIconButton = fullscreen && widget.canShowToggleFullscreenButton
        ? Container(
            clipBehavior: Clip.antiAlias,
            padding: const EdgeInsets.all(4.0),
            decoration: BoxDecoration(
              shape: BoxShape.circle,
              boxShadow: [
                BoxShadow(
                  blurRadius: 8.0,
                  color: theme.scaffoldBackgroundColor.withOpacityExt(0.7),
                ),
              ],
            ),
            child: NamidaIconButton(
              icon: Broken.maximize_3,
              iconSize: 24.0,
              onPressed: toggleFullscreen,
            ),
          )
        : null;

    final topInfoWidget = fullscreen
        ? ObxO(
            rx: Player.inst.currentItem,
            builder: (context, item) {
              final textData = item is Selectable
                  ? NamidaMiniPlayerTrack.textBuilder(item)
                  : item is YoutubeID
                  ? NamidaMiniPlayerYoutubeIDState.textBuilder(context, item)
                  : null;
              return ConstrainedBox(
                constraints: BoxConstraints(
                  minWidth: context.width, // vip
                  minHeight: 40.0, // eyeballed to match when textData is valid
                ),
                child: NamidaMouseRegion(
                  child: TapDetector(
                    onTap: null,
                    initializer: (instance) {
                      void fn(TapUpDetails d) {
                        if (item is Selectable) {
                          NamidaMiniPlayerTrack.openMenu(item.trackWithDate, item.track);
                        } else if (item is YoutubeID) {
                          NamidaMiniPlayerYoutubeIDState.openMenu(context, item, d);
                        }
                      }

                      instance
                        ..onTapUp = fn
                        ..gestureSettings = MediaQuery.maybeGestureSettingsOf(context);
                    },
                    child: Stack(
                      alignment: AlignmentGeometry.center,
                      children: [
                        Positioned(
                          left: 10.0,
                          bottom: 0,
                          top: 0,
                          child: IconButton(
                            tooltip: lang.exit,
                            icon: Icon(
                              Broken.arrow_left_2,
                              size: 22.0,
                              color: context.defaultIconColor(),
                            ),
                            onPressed: widget.onCloseFullscreenButtonTap ?? toggleFullscreen,
                          ),
                        ),
                        Padding(
                          padding: EdgeInsetsGeometry.symmetric(horizontal: 48.0 + 8.0),
                          child: textData == null
                              ? Text(
                                  lang.lyrics,
                                  maxLines: 1,
                                  overflow: TextOverflow.fade,
                                  softWrap: false,
                                  style: textTheme.displayLarge,
                                )
                              : Column(
                                  mainAxisSize: MainAxisSize.min,
                                  mainAxisAlignment: MainAxisAlignment.center,
                                  crossAxisAlignment: CrossAxisAlignment.center,
                                  children: [
                                    if (textData.firstLineGood)
                                      Text(
                                        textData.firstLine,
                                        maxLines: textData.secondLine == '' ? 2 : 1,
                                        overflow: TextOverflow.fade,
                                        softWrap: textData.secondLine.isEmpty,
                                        style: textTheme.displayMedium?.copyWith(
                                          fontSize: 17.0,
                                        ),
                                      ),
                                    if (textData.firstLineGood && textData.secondLineGood) const SizedBox(height: 4.0),
                                    if (textData.secondLineGood)
                                      Text(
                                        textData.secondLine,
                                        softWrap: false,
                                        overflow: TextOverflow.fade,
                                        style: textTheme.displayMedium?.copyWith(
                                          fontSize: 15.0,
                                        ),
                                      ),
                                  ],
                                ),
                        ),
                        Positioned(
                          right: 10.0,
                          bottom: 0,
                          top: 0,
                          child: IconButton(
                            tooltip: lang.lyrics,
                            onPressed: () {
                              final currentItem = Player.inst.currentItem.value;
                              if (currentItem == null) return;
                              showLRCSetDialog(currentItem, CurrentColor.inst.miniplayerColor);
                            },
                            icon: NamidaMiniPlayerBase.getLrcButton(
                              theme,
                              color: context.defaultIconColor(),
                            ),
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              );
            },
          )
        : null;
    final bottomControlsChildren = fullscreen
        ? [
            const WaveformMiniplayer(fixPadding: true),
            const SizedBox(height: 12.0),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Spacer(),
                NamidaHero(
                  enabled: false,
                  tag: 'MINIPLAYER_POSITION',
                  child: ObxO(
                    rx: Player.inst.nowPlayingPosition,
                    builder: (context, currentMS) => Text(
                      currentMS.milliSecondsLabel,
                      style: textTheme.displaySmall,
                    ),
                  ),
                ),
                const Spacer(),
                NamidaIconButton(
                  icon: Broken.previous,
                  iconSize: 24.0,
                  onPressed: Player.inst.previous,
                ),
                ObxO(
                  rx: Player.inst.playWhenReady,
                  builder: (context, playWhenReady) => NamidaIconButton(
                    horizontalPadding: 18.0,
                    icon: playWhenReady ? Broken.pause : Broken.play,
                    iconSize: 34.0,
                    onPressed: Player.inst.togglePlayPause,
                  ),
                ),
                NamidaIconButton(
                  icon: Broken.next,
                  iconSize: 24.0,
                  onPressed: Player.inst.next,
                ),
                const Spacer(),
                NamidaHero(
                  enabled: false,
                  tag: 'MINIPLAYER_DURATION',
                  child: ObxO(
                    rx: settings.player.displayRemainingDurInsteadOfTotal,
                    builder: (context, displayRemainingDurInsteadOfTotal) => displayRemainingDurInsteadOfTotal
                        ? ObxO(
                            rx: _currentItemDurationMS,
                            builder: (context, durMS) {
                              int finalDurMS = durMS ?? 0;

                              return ObxO(
                                rx: Player.inst.currentItem,
                                builder: (context, currentItem) {
                                  if (finalDurMS == 0 && currentItem is Selectable) {
                                    finalDurMS = currentItem.track.durationMS;
                                  }
                                  return ObxO(
                                    rx: Player.inst.nowPlayingPosition,
                                    builder: (context, toSubtract) {
                                      final msToDisplay = finalDurMS - toSubtract;
                                      return Text(
                                        "- ${msToDisplay.milliSecondsLabel}",
                                        style: textTheme.displaySmall,
                                      );
                                    },
                                  );
                                },
                              );
                            },
                          )
                        : ObxO(
                            rx: _currentItemDurationMS,
                            builder: (context, milliseconds) {
                              if (milliseconds == null || milliseconds == 0) {
                                return ObxO(
                                  rx: Player.inst.currentItem,
                                  builder: (context, currentItem) {
                                    final milliseconds = currentItem is Selectable ? currentItem.track.durationMS : 0;
                                    return Text(
                                      milliseconds.milliSecondsLabel,
                                      style: textTheme.displaySmall,
                                    );
                                  },
                                );
                              }
                              return Text(
                                milliseconds.milliSecondsLabel,
                                style: textTheme.displaySmall,
                              );
                            },
                          ),
                  ),
                ),
                const Spacer(),
              ],
            ),
            const SizedBox(height: 24.0),
            SizedBox(height: MediaQuery.paddingOf(context).bottom),
          ]
        : null;

    final pagePaddingHorizontal = fullscreen ? 0.0 : 24.0;
    late final mpAnimation = NamidaMiniPlayerBase.clampedAnimationBCP;

    final videoOrImageChild = fullscreen
        ? Positioned.fill(
            child: Stack(
              fit: StackFit.expand,
              alignment: Alignment.center,
              children: [
                ClipRect(
                  child: NamidaBlur(
                    fixArtifacts: true,
                    blur: 60.0,
                    child: FittedBox(
                      fit: BoxFit.cover,
                      child: ObxO(
                        rx: Player.inst.currentItem,
                        builder: (context, item) {
                          Widget child;
                          if (item is YoutubeID) {
                            final vidId = item.id;
                            child = YoutubeThumbnail(
                              type: ThumbnailType.video,
                              key: Key(vidId),
                              isImportantInCache: true,
                              width: context.width,
                              borderRadius: 0,
                              blur: 0,
                              disableBlurBgSizeShrink: true,
                              videoId: vidId,
                              displayFallbackIcon: false,
                              compressed: true,
                              preferLowerRes: true,
                              forceSquared: true,
                              fit: BoxFit.cover,
                            );
                          } else {
                            final track = item is Selectable ? item.track : null;
                            child = ArtworkWidget(
                              key: ValueKey(track?.path),
                              track: track,
                              path: track?.pathToImage,
                              thumbnailSize: context.width,
                              width: context.width,
                              borderRadius: 0,
                              blur: 0,
                              disableBlurBgSizeShrink: true,
                              compressed: true,
                              forceSquared: true,
                              fit: BoxFit.cover,
                            );
                          }

                          return CustomAnimatedSwitcher(
                            duration: const Duration(milliseconds: 1200),
                            switchInCurve: Curves.easeInOutQuart,
                            switchOutCurve: Curves.easeInOutQuart,
                            child: child,
                          );
                        },
                      ),
                    ),
                  ),
                ),
                Positioned.fill(
                  child: ColoredBox(
                    color: context.isDarkMode ? Colors.black.withOpacityExt(0.4) : Colors.white.withOpacityExt(0.2),
                  ),
                ),
                Positioned.fill(
                  child: ColoredBox(
                    color: context.theme.scaffoldBackgroundColor.withOpacityExt(widget.blurColorMaskOpacity),
                  ),
                ),
                // -- old design, simple color/gradient
                // Positioned.fill(
                //   child: Obx(
                //     (context) {
                //       final miniplayerColor = CurrentColor.inst.miniplayerColor;
                //       return DecoratedBox(
                //         decoration: BoxDecoration(
                //           gradient: LinearGradient(
                //             begin: Alignment.topLeft,
                //             end: Alignment.bottomRight,
                //             // -- careful with making colors non-opaque, it will cause the foreground to dim as well (no idea how :/)
                //             colors: context.isDarkMode
                //                 ? [
                //                     Color.alphaBlend(miniplayerColor.withOpacityExt(0.21), Colors.black).withOpacityExt(0.5),
                //                     Color.alphaBlend(miniplayerColor.withOpacityExt(0.15), Colors.black).withOpacityExt(0.5),
                //                   ]
                //                 : [
                //                     Color.alphaBlend(miniplayerColor.withOpacityExt(0.3), Colors.white.withOpacityExt(0.7)).withOpacityExt(0.5),
                //                     Color.alphaBlend(miniplayerColor.withOpacityExt(0.6), Colors.white.withOpacityExt(0.8)).withOpacityExt(0.5),
                //                   ],
                //           ),
                //         ),
                //         // child: widget.videoOrImage,
                //       );
                //     },
                //   ),
                // ),
              ],
            ),
          )
        : CustomAnimatedSwitcher(
            duration: const Duration(milliseconds: _lrcOpacityDurationMS),
            child: _isCurrentLineEmpty
                ? KeyedSubtree(
                    key: const ValueKey('lyrics_no_builder'),
                    child: widget.videoOrImage,
                  )
                : KeyedSubtree(
                    key: const ValueKey('lyrics_builder'),
                    child: AnimatedBuilder(
                      animation: mpAnimation,
                      builder: (context, child) {
                        final mpAnimationValue = mpAnimation.value;
                        final blur = 12.0 * mpAnimationValue;
                        late final maskColor = mpAnimationValue == 0
                            ? Colors.transparent
                            : theme.scaffoldBackgroundColor.withOpacityExt((fullscreen ? 0.8 : 0.5) * mpAnimationValue);
                        return Stack(
                          children: [
                            NamidaBlur(
                              blur: blur,
                              fixArtifacts: true,
                              child: Stack(
                                children: [
                                  widget.videoOrImage,
                                  Positioned.fill(
                                    child:
                                        !_isCurrentLineEmpty &&
                                            mpAnimationValue ==
                                                1 // animate color only when not animating mp itself
                                        ? AnimatedColoredBox(
                                            duration: const Duration(milliseconds: _lrcOpacityDurationMS),
                                            color: maskColor,
                                          )
                                        : ColoredBox(
                                            color: maskColor,
                                          ),
                                  ),
                                ],
                              ),
                            ),
                          ],
                        );
                      },
                    ),
                  ),
          );

    final middleLyricsStackWidget = Stack(
      fit: StackFit.loose,
      alignment: AlignmentGeometry.center,
      children: [
        Listener(
          onPointerDown: _onPointerDown,
          onPointerUp: _onPointerUp,
          onPointerCancel: _onPointerUp,
          onPointerSignal: _onPointerSignal,
          child: ShaderFadingWidget(
            biggerValues: fullscreen,
            child: ConstrainedBox(
              constraints: BoxConstraints(maxWidth: maxLyricsWidth),
              child: Builder(
                builder: (context) {
                  return Obx(
                    (context) {
                      final lrc = Lyrics.inst.currentLyricsLRC.valueR;
                      if (lrc == null) {
                        final textWrapper = Lyrics.inst.currentLyricsText.valueR;
                        if (!_checkIfTextEmpty(textWrapper.text)) {
                          final textDirection = textWrapper.isRTL ? TextDirection.rtl : TextDirection.ltr;
                          return Align(
                            // -- Align vip to center widget
                            child: SmoothSingleChildScrollView(
                              padding: const EdgeInsets.symmetric(horizontal: 24.0),
                              controller: Lyrics.inst.textScrollController,
                              child: Directionality(
                                textDirection: textDirection,
                                child: Column(
                                  children: [
                                    SizedBox(height: _paddingVertical),
                                    Text(
                                      textWrapper.text,
                                      style: plainLyricsTextStyle,
                                      textAlign: alignAtStart ? TextAlign.start : TextAlign.center,
                                    ),
                                    SizedBox(height: _paddingVertical),
                                  ],
                                ),
                              ),
                            ),
                          );
                        }
                      }

                      final miniplayerColor = CurrentColor.inst.miniplayerColor;
                      final personCount = currentLRC?.personCount ?? 1;

                      return ObxO(
                        rx: _latestUpdatedLineInfo,
                        builder: (context, selectedInfo) {
                          final selectedIndex = selectedInfo?.$2;
                          final selectedLineTimestamp = selectedInfo?.$1;
                          return CustomAnimatedSwitcher(
                            duration: const Duration(milliseconds: 600),
                            switchInCurve: Curves.easeInOutQuart,
                            switchOutCurve: Curves.easeInOutQuart,
                            child: lyrics.isEmpty
                                ? const SizedBox(
                                    key: ValueKey('empty_lrc'),
                                  )
                                : SuperSmoothListView.builder(
                                    key: ValueKey('valid_lrc'),
                                    padding: EdgeInsets.symmetric(vertical: _paddingVertical),
                                    controller: _scrollController,
                                    listController: _listController,
                                    itemCount: lyrics.length,
                                    itemBuilder: (context, index) {
                                      // -- usually not needed, but helps cuz sometimes gets called on stale index
                                      if (index >= lyrics.length) return const SizedBox.shrink();

                                      final distanceDiffFromSelected = selectedIndex == null ? null : index - selectedIndex; // -- does not account for empty lines
                                      final distanceDiffFromSelectedAbs = distanceDiffFromSelected?.abs();
                                      final lrc = lyrics[index];
                                      String text = lrc.readableText;
                                      final parts = lrc.parts;
                                      final person = lrc.person;
                                      final isBGLyrics = lrc.isBGLyrics;
                                      final indicesForTimestamp = highlightTimestampsMap[lrc.timestamp];
                                      final textDirection = lrc.isRTL == true ? TextDirection.rtl : TextDirection.ltr;

                                      final selected = distanceDiffFromSelected == 0 || isBGLyrics || selectedLineTimestamp == lrc.timestamp;
                                      final selectedAndEmpty = selected && _checkIfTextEmpty(text);
                                      var bgColor = selected && !isBGLyrics
                                          ? Color.alphaBlend(miniplayerColor.withAlpha(140), theme.scaffoldBackgroundColor).withOpacityExt(
                                              selectedAndEmpty
                                                  ? 0.1
                                                  : fullscreen
                                                  ? 0.4
                                                  : 0.5,
                                            )
                                          : null;

                                      EdgeInsetsGeometry lineMargin = EdgeInsets.symmetric(
                                        vertical: /* (selected ? 2.0 : 0.0) + */ (fullscreen ? 8.0 : 2.0),
                                        horizontal: fullscreen ? 10.0 : 8.0,
                                      );

                                      EdgeInsetsGeometry padding = selectedAndEmpty
                                          ? const EdgeInsets.symmetric(
                                              vertical: 3.0,
                                              horizontal: 24.0,
                                            )
                                          : fullscreen
                                          ? const EdgeInsets.symmetric(
                                              vertical: 8.0,
                                              horizontal: 8.0,
                                            )
                                          : const EdgeInsets.symmetric(
                                              vertical: 8.0,
                                              horizontal: 8.0,
                                            );

                                      BorderRadius borderRadius = selectedAndEmpty ? BorderRadius.circular(5.0.multipliedRadius) : BorderRadius.circular(8.0.multipliedRadius);

                                      TextAlign textAlign = alignAtStart ? TextAlign.start : TextAlign.center;
                                      AlignmentDirectional alignment = alignAtStart ? AlignmentDirectional.centerStart : AlignmentDirectional.center;
                                      double normalLineColorOpacity = 0.5;
                                      (double, FontWeight)? fontModifier;
                                      TextStyle textStyle;

                                      if (person != null && personCount > 0) {
                                        if (personCount == 1) {
                                          // -- keep defaults
                                        } else if (person == 0) {
                                          // -- bg
                                          textAlign = TextAlign.center;
                                          alignment = AlignmentDirectional.center;
                                          fontModifier = (0.75, FontWeight.w400);
                                        } else if (person == 1) {
                                          // -- v1
                                          textAlign = TextAlign.start;
                                          alignment = AlignmentDirectional.centerStart;
                                          padding = padding.add(EdgeInsetsDirectional.only(start: 12.0, end: 64.0));
                                        } else if (person == 2) {
                                          // -- v2
                                          textAlign = TextAlign.end;
                                          alignment = AlignmentDirectional.centerEnd;
                                          padding = padding.add(EdgeInsetsDirectional.only(start: 64.0, end: 12.0));
                                        } else if (person >= 3) {
                                          // -- v3
                                          textAlign = TextAlign.center;
                                          alignment = AlignmentDirectional.center;
                                        }
                                        if (selected) {
                                          textStyle = normalTextStyle.copyWith(
                                            color: Colors.white.withOpacityExt(0.75),
                                          );
                                        } else if (distanceDiffFromSelected != null && distanceDiffFromSelected <= 0) {
                                          // -- lines before current in word synced lyrics
                                          textStyle = normalTextStyle.copyWith(
                                            color: normalTextStyle.color?.withOpacityExt(normalLineColorOpacity) ?? Colors.transparent,
                                          );
                                        } else {
                                          // -- lines after current in word synced lyrics
                                          normalLineColorOpacity = 0.2;
                                          textStyle = normalTextStyle.copyWith(
                                            color: normalTextStyle.color?.withOpacityExt(normalLineColorOpacity) ?? Colors.transparent,
                                          );
                                        }
                                      } else {
                                        // -- lines in normal synced lyrics
                                        if (distanceDiffFromSelected != null) {
                                          normalLineColorOpacity = distanceDiffFromSelectedAbs == 1
                                              ? 0.5
                                              : distanceDiffFromSelectedAbs == 2
                                              ? 0.4
                                              : 0.25;
                                        }

                                        if (selected) {
                                          textStyle = normalTextStyle;
                                        } else {
                                          textStyle = normalTextStyle.copyWith(
                                            color: normalTextStyle.color?.withOpacityExt(normalLineColorOpacity) ?? Colors.transparent,
                                          );
                                        }
                                      }
                                      if (fontModifier != null) {
                                        final size = fontModifier.$1;
                                        final weigth = fontModifier.$2;
                                        textStyle = textStyle.copyWith(
                                          fontSize: size == 1.0 ? null : textStyle.fontSize! * size,
                                          fontWeight: weigth,
                                        );
                                      }

                                      if (indicesForTimestamp != null && indicesForTimestamp.length > 1) {
                                        final isFirst = index == indicesForTimestamp.first;
                                        final isLast = index == indicesForTimestamp.last;
                                        final isSecondaryLanguageLine = !isFirst;

                                        if (isSecondaryLanguageLine) {
                                          final multiplier = fullscreen ? 0.75 : 0.85;
                                          textStyle = textStyle.copyWith(
                                            fontSize: textStyle.fontSize! * multiplier,
                                          );
                                          if (bgColor != null) {
                                            bgColor = bgColor.withOpacityExt(bgColor.a * 0.75);
                                          }
                                        }

                                        if (!isFirst) {
                                          lineMargin = lineMargin.subtract(
                                            EdgeInsetsDirectional.only(
                                              top: lineMargin.resolve(textDirection).top,
                                            ),
                                          );
                                          padding = padding.subtract(
                                            EdgeInsetsDirectional.only(
                                              top: padding.resolve(textDirection).top * 0.4,
                                            ),
                                          );
                                          borderRadius = borderRadius.copyWith(
                                            topRight: Radius.zero,
                                            topLeft: Radius.zero,
                                          );
                                        }

                                        if (!isLast) {
                                          lineMargin = lineMargin.subtract(
                                            EdgeInsetsDirectional.only(
                                              bottom: lineMargin.resolve(textDirection).bottom,
                                            ),
                                          );
                                          padding = padding.subtract(
                                            EdgeInsetsDirectional.only(
                                              bottom: padding.resolve(textDirection).bottom * 0.4,
                                            ),
                                          );
                                          borderRadius = borderRadius.copyWith(
                                            bottomRight: Radius.zero,
                                            bottomLeft: Radius.zero,
                                          );
                                        }
                                      }

                                      final textWidget = selected && parts != null && parts.isNotEmpty
                                          ? _TextWithFadingProgress(
                                              parts: parts,
                                              textStyle: textStyle,
                                              textAlign: textAlign,
                                              textDirection: textDirection,
                                            )
                                          : Text(
                                              text,
                                              style: textStyle,
                                              textAlign: textAlign,
                                              // softWrap: false, // keep the text steady while animating mp
                                            );

                                      return Directionality(
                                        textDirection: textDirection,
                                        child: Align(
                                          alignment: alignment, // needed for constraints
                                          child: ConstrainedBox(
                                            constraints: BoxConstraints(
                                              maxWidth: widget.maxWidth != null ? widget.maxWidth! : double.infinity,
                                            ),
                                            child: Stack(
                                              alignment: Alignment.center,
                                              children: [
                                                Positioned.fill(
                                                  child: Material(
                                                    type: MaterialType.transparency,
                                                    child: InkWell(
                                                      splashFactory: InkSparkle.splashFactory,
                                                      onTap: () {
                                                        _canAnimateScroll.value = true;
                                                        _currentIndex = null; // reset so that tapping the current line still animates to it
                                                        Player.inst.seek(lrc.timestamp); //  should auto scroll bcz position changes
                                                      },
                                                    ),
                                                  ),
                                                ),
                                                IgnorePointer(
                                                  child: NamidaHero(
                                                    tag: 'LYRICS_LINE_${lrc.timestamp}',
                                                    enabled: false,
                                                    child: AnimatedScale(
                                                      alignment: alignment.resolve(textDirection),
                                                      duration: const Duration(milliseconds: 400),
                                                      curve: Curves.easeInOutCubicEmphasized,
                                                      scale: selected ? 1.0 : 0.95,
                                                      child: NamidaInkWell(
                                                        alignment: alignment,
                                                        bgColor: bgColor,
                                                        decoration: BoxDecoration(
                                                          borderRadius: borderRadius,
                                                        ),
                                                        animationDurationMS: 300,
                                                        margin: lineMargin,
                                                        padding: padding,
                                                        child: textWidget,
                                                      ),
                                                    ),
                                                  ),
                                                ),
                                              ],
                                            ),
                                          ),
                                        ),
                                      );
                                    },
                                  ),
                          );
                        },
                      );
                    },
                  );
                },
              ),
            ),
          ),
        ),
      ],
    );

    Widget mainLyricsWidget = Column(
      children: [
        if (topInfoWidget != null) ...[
          const SizedBox(height: 12.0),
          topInfoWidget,
        ],
        Expanded(
          child: Stack(
            alignment: Alignment.center,
            children: [
              Positioned.fill(
                child: AnimatedOpacity(
                  duration: const Duration(milliseconds: _lrcOpacityDurationMS),
                  opacity: _isCurrentLineEmpty ? 0.0 : 1.0,
                  child: FadeIgnoreTransition(
                    opacity: mpAnimation,
                    child: fullscreen || !widget.allowOverflow
                        ? Padding(
                            padding: EdgeInsets.symmetric(horizontal: pagePaddingHorizontal),
                            child: middleLyricsStackWidget,
                          )
                        : OverflowBox(
                            maxWidth: Dimensions.inst.miniplayerMaxWidth - pagePaddingHorizontal * 2, // keep the text steady while animating mp
                            child: Padding(
                              padding: EdgeInsets.symmetric(horizontal: pagePaddingHorizontal),
                              child: middleLyricsStackWidget,
                            ),
                          ),
                  ),
                ),
              ),
              if (fullscreenIconButton != null)
                Positioned(
                  bottom: 8.0,
                  right: 0.0,
                  child: fullscreenIconButton,
                ),

              if (fullscreen)
                Positioned(
                  bottom: 12.0,
                  right: 12.0,
                  child: ObxO(
                    rx: _canAnimateScroll,
                    builder: (context, canAnimateScroll) => DelayedAnimatedShow(
                      show: !canAnimateScroll,
                      showDelay: const Duration(milliseconds: 1200),
                      duration: const Duration(milliseconds: 600),
                      child: NamidaInkWell(
                        borderRadius: 10.0,
                        onTap: () {
                          _updateHighlightedLine(Player.inst.nowPlayingPosition.value, forceAnimate: true, force: true);
                          _canAnimateScroll.value = true;
                        },
                        padding: const EdgeInsets.symmetric(vertical: 8.0),
                        bgColor: context.theme.colorScheme.secondary.withOpacityExt(0.3),
                        child: Row(
                          mainAxisSize: .min,
                          children: [
                            const SizedBox(width: 12.0),
                            Icon(
                              Broken.cd,
                              size: 18.0,
                            ),
                            const SizedBox(width: 6.0),
                            Text(
                              lang.jump,
                              style: context.textTheme.displayMedium,
                            ),
                            const SizedBox(width: 12.0),
                          ],
                        ),
                      ),
                    ),
                  ),
                ),
            ],
          ),
        ),
        ...?bottomControlsChildren,
        if (widget.bottomPadding != null) widget.bottomPadding!,
      ],
    );
    if (fullscreen && widget.useSafeArea) {
      mainLyricsWidget = SafeArea(
        bottom: false,
        child: mainLyricsWidget,
      );
    }
    Widget finalStack = Stack(
      alignment: Alignment.center,
      children: [
        videoOrImageChild,
        ConstrainedBox(
          constraints: BoxConstraints(
            maxHeight: widget.maxHeight != null ? widget.maxHeight! * 0.75 : double.infinity,
            maxWidth: widget.maxWidth != null ? widget.maxWidth! : double.infinity,
          ),
          child: mainLyricsWidget,
        ),
        Positioned.fill(
          child: ScaleDetector(
            onScaleStart: (details) => _previousFontMultiplier = _fontMultiplier,
            onScaleUpdate: (details) => refreshState(() => _fontMultiplier = (details.scale * _previousFontMultiplier).clampDouble(0.5, 2.0)),
            onScaleEnd: (details) => widget.isFullScreenView ? settings.save(fontScaleLRCFull: _fontMultiplier) : settings.save(fontScaleLRC: _fontMultiplier),
          ),
        ),
      ],
    );

    if (fullscreen) {
      finalStack = BackgroundWrapper(
        child: finalStack,
      );
    }

    return finalStack;
  }
}

// class _TextWithFadingProgressOld extends StatelessWidget {
//   final List<LrcLinePart> parts;
//   final TextStyle textStyle;
//   final TextAlign textAlign;
//   final TextDirection textDirection;

//   const _TextWithFadingProgressOld({
//     required this.parts,
//     required this.textStyle,
//     required this.textAlign,
//     required this.textDirection,
//   });

//   @override
//   Widget build(BuildContext context) {
//     return ObxO(
//       rx: Player.inst.playWhenReady,
//       builder: (context, playWhenReady) => ObxO(
//         rx: Player.inst.nowPlayingPosition,
//         builder: (context, currentPosition) => Text.rich(
//           TextSpan(
//             children: parts.mapIndexed(
//               (_, indexPre) {
//                 final indexEffective = switch (textDirection) {
//                   TextDirection.ltr => indexPre,
//                   TextDirection.rtl => parts.length - indexPre - 1,
//                 };
//                 final e = parts[indexEffective];
//                 final textPart = e.lyrics;
//                 final normalColor = textStyle.color!;
//                 final dimmedColor = normalColor.withValues(
//                   alpha: 0.25,
//                 );
//                 final didReachTimeStampForPart = Duration(milliseconds: currentPosition) > e.startTimestamp;

//                 final child = RichText(
//                   text: TextSpan(
//                     text: textPart,
//                     style: textStyle, // dont dim here
//                   ),
//                   // textDirection: textDirection,
//                   textAlign: TextAlign.start, // -- otherwise multi-part word will appear split
//                   // -- fixes artifacts with shader
//                   textHeightBehavior: const TextHeightBehavior(
//                     applyHeightToFirstAscent: false,
//                     applyHeightToLastDescent: false,
//                   ),
//                   // softWrap: false, // keep the text steady while animating mp
//                 );

//                 // -- more accurate but position updates are slower, so it feels jittery
//                 // final eStart = e.startTimestamp.inMilliseconds;
//                 // final nextStart = e.endTimestamp.inMilliseconds;
//                 // final total = nextStart - eStart;
//                 // double progress = (currentPosition - eStart) / total;
//                 // progress = progress.clampDouble(0.0, 1.0);

//                 var animationDuration = !playWhenReady ? Duration.zero : e.endTimestamp - e.startTimestamp;
//                 if (animationDuration <= Duration.zero) animationDuration = const Duration(milliseconds: 10);
//                 return WidgetSpan(
//                   child: TweenAnimationBuilder(
//                     duration: animationDuration,
//                     tween: DoubleTween(begin: 0.0, end: didReachTimeStampForPart ? 1.0 : 0.0),
//                     builder: (context, value, _) {
//                       final progress = value ?? 1.0;
//                       // if (progress >= 1.0) return child; // color be mismatching
//                       return ShaderMask(
//                         shaderCallback: (bounds) => LinearGradient(
//                           begin: AlignmentDirectional.centerStart,
//                           end: AlignmentDirectional.centerEnd,
//                           colors: [
//                             normalColor,
//                             normalColor,
//                             dimmedColor,
//                           ],
//                           stops: [
//                             0,
//                             progress,
//                             progress == 0 ? 0.0 : progress + 0.1,
//                           ],
//                         ).createShader(bounds, textDirection: textDirection),
//                         blendMode: BlendMode.dstIn,
//                         child: ClipRect(
//                           // -- clip is important to avoid artifacts caused by font exisitng outside shader mask area
//                           child: child,
//                         ),
//                       );
//                     },
//                   ),
//                 );
//               },
//             ).toFixedList(),
//           ),
//           textDirection: textDirection,
//           textAlign: textAlign,
//         ),
//       ),
//     );
//   }
// }

/// Generated by claude.ai
/// Reason: no enough experience with deep rendering stuff
///
/// the old version [_TextWithFadingProgressOld] laid out each part with its own shader mask,
/// causing text to jump due to different layout ways (Text vs TextSpan.children)
///
/// Karaoke-style word-synced line.
///
/// Instead of wrapping each timed part in its own [WidgetSpan] (which turns every
/// word into an atomic inline box -> spaces no longer collapse at line breaks and
/// words no longer pack tightly, so the text wraps differently than a plain [Text]),
/// this lays out the whole line as ONE real paragraph via a [TextPainter] and reveals
/// the "sung" region on top of a dimmed base. Because it's a single paragraph, wrapping
/// is identical to [Text] and the reveal flows correctly across any number of lines.
class _TextWithFadingProgress extends StatefulWidget {
  final List<LrcLinePart> parts;
  final TextStyle textStyle;
  final TextAlign textAlign;
  final TextDirection textDirection;

  const _TextWithFadingProgress({
    required this.parts,
    required this.textStyle,
    required this.textAlign,
    required this.textDirection,
  });

  @override
  State<_TextWithFadingProgress> createState() => _TextWithFadingProgressState();
}

class _TextWithFadingProgressState extends State<_TextWithFadingProgress> with SingleTickerProviderStateMixin {
  /// unbounded; its value is the number of "sung" characters (fractional) of [_fullText].
  late final AnimationController _fillController;

  late String _fullText;
  late List<int> _partCharStarts;
  late List<int> _partCharEnds;
  int _totalChars = 0;

  // -- laid-out painters cached so that per-frame paint() never re-runs layout.
  // -- relaid only when text / style / width / textScaler actually change.
  TextPainter? _tpDim;
  TextPainter? _tpFull;
  double _laidOutMaxWidth = -1;
  TextScaler _laidOutScaler = TextScaler.noScaling;
  TextStyle? _laidOutStyle;
  String? _laidOutText;

  @override
  void initState() {
    super.initState();
    _fillController = AnimationController.unbounded(vsync: this);
    _computeParts();
    _syncFill();
    Player.inst.nowPlayingPosition.addListener(_syncFill);
    Player.inst.playWhenReady.addListener(_syncFill);
  }

  void _computeParts() {
    final parts = widget.parts;
    _partCharStarts = List.filled(parts.length, 0);
    _partCharEnds = List.filled(parts.length, 0);
    final sb = StringBuffer();
    int offset = 0;
    for (int i = 0; i < parts.length; i++) {
      final txt = parts[i].lyrics;
      _partCharStarts[i] = offset;
      offset += txt.length;
      _partCharEnds[i] = offset;
      sb.write(txt);
    }
    _fullText = sb.toString();
    _totalChars = offset;
  }

  @override
  void didUpdateWidget(covariant _TextWithFadingProgress oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (!identical(oldWidget.parts, widget.parts)) {
      _computeParts();
      _syncFill();
    }
  }

  /// Re-targets [_fillController] towards the end of the currently-sung part over the
  /// time remaining for that part, so the fill sweeps smoothly even though position
  /// updates are coarse. Called on every position/playWhenReady change.
  void _syncFill() {
    final parts = widget.parts;
    if (parts.isEmpty || _totalChars == 0) return;

    final posMicros = Player.inst.nowPlayingPosition.value * 1000;
    final playing = Player.inst.playWhenReady.value;

    // -- last part whose start has already been reached
    int active = -1;
    for (int i = 0; i < parts.length; i++) {
      if (parts[i].startTimestamp.inMicroseconds <= posMicros) {
        active = i;
      } else {
        break;
      }
    }

    double exactChars;
    double targetChars;
    int remainingMicros = 0;

    if (active < 0) {
      exactChars = 0.0;
      targetChars = 0.0;
    } else {
      final startMicros = parts[active].startTimestamp.inMicroseconds;
      var endMicros = parts[active].endTimestamp.inMicroseconds;
      if (endMicros <= startMicros) endMicros = startMicros + 10000; // -- 10ms min
      final startChar = _partCharStarts[active];
      final endChar = _partCharEnds[active];
      if (posMicros >= endMicros) {
        exactChars = endChar.toDouble();
        targetChars = endChar.toDouble();
      } else {
        final frac = ((posMicros - startMicros) / (endMicros - startMicros)).clampDouble(0.0, 1.0);
        exactChars = startChar + frac * (endChar - startChar);
        targetChars = endChar.toDouble();
        remainingMicros = endMicros - posMicros;
      }
    }

    // -- re-sync only on discontinuity (seek); otherwise let the ongoing animation flow
    if ((exactChars - _fillController.value).abs() > 1.5) {
      _fillController.value = exactChars;
    }

    if (playing && remainingMicros > 0 && targetChars > _fillController.value) {
      _fillController.animateTo(
        targetChars,
        duration: Duration(microseconds: remainingMicros),
        curve: Curves.linear,
      );
    } else {
      _fillController.stop();
      _fillController.value = exactChars;
    }
  }

  /// (Re)lays out the cached painters only when an input that affects layout changed.
  /// Keeps [paint] layout-free (layout is the costly part; painting a laid-out painter is cheap).
  void _ensurePainters(double maxWidth, TextScaler textScaler) {
    final style = widget.textStyle;
    final upToDate = _tpFull != null && _laidOutMaxWidth == maxWidth && _laidOutScaler == textScaler && _laidOutStyle == style && _laidOutText == _fullText;
    if (upToDate) return;

    _tpDim?.dispose();
    _tpFull?.dispose();
    final normalColor = style.color ?? const Color(0xFFFFFFFF);
    final dimmedColor = normalColor.withValues(alpha: 0.25);
    _tpDim = _layoutPainter(dimmedColor, style, maxWidth, textScaler);
    _tpFull = _layoutPainter(normalColor, style, maxWidth, textScaler);
    _laidOutMaxWidth = maxWidth;
    _laidOutScaler = textScaler;
    _laidOutStyle = style;
    _laidOutText = _fullText;
  }

  TextPainter _layoutPainter(Color color, TextStyle style, double maxWidth, TextScaler textScaler) {
    return TextPainter(
      text: TextSpan(
        text: _fullText,
        style: style.copyWith(
          color: color,
          height: 1.5, // -- ensure it clips more for higher glyphs, shouldn't affect anything else even with very high number
        ),
      ),
      textAlign: widget.textAlign,
      textDirection: widget.textDirection,
      textScaler: textScaler,
      textWidthBasis: TextWidthBasis.parent,
      textHeightBehavior: TextHeightBehavior(
        applyHeightToFirstAscent: false,
        applyHeightToLastDescent: false,
      ),
    )..layout(maxWidth: maxWidth);
  }

  @override
  void dispose() {
    Player.inst.nowPlayingPosition.removeListener(_syncFill);
    Player.inst.playWhenReady.removeListener(_syncFill);
    _tpDim?.dispose();
    _tpFull?.dispose();
    _fillController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final textScaler = MediaQuery.textScalerOf(context);
    return LayoutBuilder(
      builder: (context, constraints) {
        final maxWidth = constraints.maxWidth.isFinite ? constraints.maxWidth : double.infinity;
        _ensurePainters(maxWidth, textScaler);
        final full = _tpFull!;
        return RepaintBoundary(
          child: CustomPaint(
            size: full.size,
            painter: _KaraokeTextPainter(
              dim: _tpDim!,
              full: full,
              total: _totalChars,
              textDirection: widget.textDirection,
              fill: _fillController,
            ),
          ),
        );
      },
    );
  }
}

/// Generated by claude.ai
/// Reason: no enough experience with deep rendering stuff
class _KaraokeTextPainter extends CustomPainter {
  final TextPainter dim;
  final TextPainter full;
  final int total;
  final TextDirection textDirection;
  final Animation<double> fill;

  static const double _featherPx = 20.0;

  const _KaraokeTextPainter({
    required this.dim,
    required this.full,
    required this.total,
    required this.textDirection,
    required this.fill,
  }) : super(repaint: fill);

  @override
  void paint(Canvas canvas, Size size) {
    // -- base dimmed layer (whole line)
    dim.paint(canvas, Offset.zero);

    if (total == 0) return;
    var fillChars = fill.value;
    if (fillChars.isNaN) fillChars = 0.0;
    fillChars = fillChars.clampDouble(0.0, total.toDouble());
    if (fillChars <= 0) return;

    if (fillChars >= total) {
      full.paint(canvas, Offset.zero); // -- whole line sung
      return;
    }

    final n = fillChars.floor();
    final frac = fillChars - n;

    // -- the active char's box gives us the boundary line's vertical band + the sweep x position
    final cboxes = full.getBoxesForSelection(
      TextSelection(baseOffset: n, extentOffset: n + 1),
      boxHeightStyle: ui.BoxHeightStyle.max,
    );
    if (cboxes.isEmpty) {
      _paintHardSung(canvas, n); // -- fallback (e.g. active char is a zero-width glyph)
      return;
    }
    final cb = cboxes.first;
    final cRect = cb.toRect();
    final ltr = cb.direction == TextDirection.ltr;
    final lineTop = cRect.top;
    final lineBottom = cRect.bottom;
    final boundaryX = ltr ? cRect.left + frac * cRect.width : cRect.right - frac * cRect.width;

    // -- one small saveLayer: paint the full-color line, then carve it down to the sung region
    // -- with a dstIn mask. Lines above the sweep stay opaque, the sweep line feathers, lines below clear.
    canvas.saveLayer(Offset.zero & size, Paint());
    full.paint(canvas, Offset.zero);

    if (lineTop > 0) {
      canvas.drawRect(
        Rect.fromLTRB(0, 0, size.width, lineTop),
        Paint()
          ..blendMode = BlendMode.dstIn
          ..color = const Color(0xFFFFFFFF), // -- keep everything above
      );
    }
    canvas.drawRect(
      Rect.fromLTRB(0, lineTop, size.width, lineBottom),
      Paint()
        ..blendMode = BlendMode.dstIn
        ..shader = _featherShader(size.width, boundaryX, ltr), // -- feather the sweep line
    );
    if (lineBottom < size.height) {
      canvas.drawRect(
        Rect.fromLTRB(0, lineBottom, size.width, size.height),
        Paint()
          ..blendMode = BlendMode.dstIn
          ..color = const Color(0x00000000), // -- clear everything below
      );
    }
    canvas.restore();
  }

  Shader _featherShader(double width, double boundaryX, bool ltr) {
    final w = width <= 0 ? 1.0 : width;
    const white = Color(0xFFFFFFFF);
    const clear = Color(0x00FFFFFF);
    final rect = Rect.fromLTWH(0, 0, w, 1);
    if (ltr) {
      final s1 = (boundaryX / w).clampDouble(0.0, 1.0);
      var s2 = ((boundaryX + _featherPx) / w).clampDouble(0.0, 1.0);
      if (s2 <= s1) s2 = (s1 + 0.001).clampDouble(0.0, 1.0);
      return LinearGradient(
        begin: Alignment.centerLeft,
        end: Alignment.centerRight,
        colors: const [white, white, clear],
        stops: [0.0, s1, s2],
      ).createShader(rect);
    } else {
      final s2 = (boundaryX / w).clampDouble(0.0, 1.0);
      var s1 = ((boundaryX - _featherPx) / w).clampDouble(0.0, 1.0);
      if (s1 >= s2) s1 = (s2 - 0.001).clampDouble(0.0, 1.0);
      return LinearGradient(
        begin: Alignment.centerLeft,
        end: Alignment.centerRight,
        colors: const [clear, white, white],
        stops: [s1, s2, 1.0],
      ).createShader(rect);
    }
  }

  void _paintHardSung(Canvas canvas, int n) {
    if (n <= 0) return;
    final boxes = full.getBoxesForSelection(
      TextSelection(baseOffset: 0, extentOffset: n),
      boxHeightStyle: ui.BoxHeightStyle.max,
    );
    if (boxes.isEmpty) return;
    canvas.save();
    final path = Path();
    for (final b in boxes) {
      path.addRect(b.toRect());
    }
    canvas.clipPath(path);
    full.paint(canvas, Offset.zero);

    canvas.restore();
  }

  @override
  bool shouldRepaint(covariant _KaraokeTextPainter old) {
    return old.dim != dim || old.full != full || old.total != total || old.textDirection != textDirection;
  }
}
