Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7f507836b | ||
|
|
1395cfd414 | ||
|
|
15bebd6e47 |
@@ -10,7 +10,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/RarDog/Lunaris/releases"><img src="https://img.shields.io/badge/Version-3.1.5-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.1.5" /></a>
|
||||
<a href="https://github.com/RarDog/Lunaris/releases"><img src="https://img.shields.io/badge/Version-3.1.8-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.1.8" /></a>
|
||||
<img src="https://img.shields.io/badge/Flutter-3.47+-02569B?style=for-the-badge&logo=flutter&logoColor=white" alt="Flutter" />
|
||||
<img src="https://img.shields.io/badge/Platform-Android%20%7C%20Linux%20%7C%20Windows-00C853?style=for-the-badge" alt="Platforms" />
|
||||
<a href="https://github.com/RarDog/Lunaris/wiki"><img src="https://img.shields.io/badge/Wiki-Documentation-FF6F00?style=for-the-badge&logo=gitbook&logoColor=white" alt="Wiki" /></a>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
const appDisplayVersion = '3.1.5';
|
||||
const appBuildNumber = 66;
|
||||
const appDisplayVersion = '3.1.8';
|
||||
const appBuildNumber = 69;
|
||||
const appReleaseChannel = 'Stable';
|
||||
|
||||
@@ -1,4 +1,33 @@
|
||||
const lunarisChangelog = [
|
||||
LunarisChange(
|
||||
version: '3.1.8',
|
||||
title: 'Безупречный полноэкранный плеер: устранение остановки при развертывании и отзывчивые кнопки управления',
|
||||
bullets: [
|
||||
'Непрерывное воспроизведение при открытии на весь экран: видео больше не останавливается самопроизвольно при входе в полноэкранный режим или смене ориентации устройства.',
|
||||
'Корректный статус воспроизведения: устранен сброс кнопки на «Пуск» при переходе в полный экран — видео продолжает воспроизведение без ложных остановок.',
|
||||
'Мгновенный отклик кнопок «Пуск/Пауза» и перемотки: жесты управления поверхностью (двойной тап, смахивание громкости) перемещены в фоновый слой, исключая перехват и отмену нажатий по кнопкам управления.',
|
||||
'Синхронизация состояния при сворачивании: плеер точно запоминает состояние паузы/воспроизведения при выходе из полноэкранного режима обратно в пост.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '3.1.7',
|
||||
title: 'Устранение критического сбоя при открытии видеопоста (SIGSEGV в libmpv)',
|
||||
bullets: [
|
||||
'Устранение нативного вылета: полностью удалены небезопасные прямые FFI-вызовы setProperty к mpv, приводившие к немедленному падению приложения на Android при инициализации видео.',
|
||||
'Безопасная конфигурация декодирования: перевод настроек аппаратного ускорения и фоллбэка кодеков на официальный типобезопасный VideoControllerConfiguration (media_kit_video).',
|
||||
'Надежный фоллбэк при редких ошибках кодека: при несовместимости аппаратного декодера плеер бесшовно переключается на программный FFmpeg-декодер без риска краша.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '3.1.6',
|
||||
title: 'Надежный полноэкранный плеер, сохранение провайдеров в поиске, громкость и устранение ошибки кодека',
|
||||
bullets: [
|
||||
'Полноэкранный плеер — отзывчивая кнопка Пуск/Пауза: центральная кнопка управления воспроизведением полностью переработана на непроницаемый GestureDetector со сквиркл-откликом, гарантируя мгновенное срабатывание без перехвата внешними жестами.',
|
||||
'Сохранение источников при нажатии на крестик в поиске: клик по кнопке сброса в строке поиска теперь очищает только поисковый запрос и теги, никогда не сбрасывая выбранные источники до «Все».',
|
||||
'Безупречное сохранение громкости: громкость видеоплеера корректно инициализируется из настроек, сохраняется при регулировках жестом и переходе между постами, не сбрасываясь на 100% при открытии нового видео и перезапуске приложения.',
|
||||
'Автоматический fallback для ошибки Could not open codec: видеоплеер получил безопасные профили декодирования и автопереключение на программный лавк-декодер (FFmpeg software fallback) при редких сбоях аппаратного ускорения.',
|
||||
],
|
||||
),
|
||||
LunarisChange(
|
||||
version: '3.1.5',
|
||||
title: 'Сохранение громкости видео, исправление полноэкранного плеера, фильтрации и удобства комментариев',
|
||||
|
||||
@@ -104,7 +104,7 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
},
|
||||
onCleared: () {
|
||||
_query = '';
|
||||
widget.onClearFilters();
|
||||
widget.onSearch('');
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -235,7 +235,7 @@ class _FeedToolbarState extends State<FeedToolbar> {
|
||||
},
|
||||
onCleared: () {
|
||||
_query = '';
|
||||
widget.onClearFilters();
|
||||
widget.onSearch('');
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -75,6 +75,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
late double _currentVolume;
|
||||
String? _videoError;
|
||||
bool _retriedFormatError = false;
|
||||
bool _softwareDecodingFallback = false;
|
||||
Timer? _hideTimer;
|
||||
StreamSubscription<String>? _errorSubscription;
|
||||
StreamSubscription<Duration>? _positionSubscription;
|
||||
@@ -105,6 +106,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
_videoIndex = 0;
|
||||
_videoError = null;
|
||||
_retriedFormatError = false;
|
||||
_softwareDecodingFallback = false;
|
||||
_imageUrls = _buildImageUrls(widget.post);
|
||||
_videoUrls = _buildVideoUrls(widget.post);
|
||||
if (_isVideo(widget.post) && _videoUrls.isNotEmpty) {
|
||||
@@ -115,6 +117,27 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
unawaited(_retryVideo());
|
||||
}
|
||||
}
|
||||
if (oldWidget.initialVolume != widget.initialVolume) {
|
||||
_currentVolume = widget.initialVolume;
|
||||
unawaited(_applyVolume());
|
||||
}
|
||||
if (oldWidget.initialMuted != widget.initialMuted) {
|
||||
_muted = widget.initialMuted;
|
||||
unawaited(_applyVolume());
|
||||
}
|
||||
if (oldWidget.initialHalfVolume != widget.initialHalfVolume) {
|
||||
_halfVolume = widget.initialHalfVolume;
|
||||
unawaited(_applyVolume());
|
||||
}
|
||||
if (oldWidget.initialLoop != widget.initialLoop) {
|
||||
_loopVideo = widget.initialLoop;
|
||||
unawaited(_player?.setPlaylistMode(
|
||||
_loopVideo ? PlaylistMode.single : PlaylistMode.none,
|
||||
));
|
||||
}
|
||||
if (oldWidget.initialCoverVideo != widget.initialCoverVideo) {
|
||||
_coverVideo = widget.initialCoverVideo;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -177,6 +200,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
onVolumeChanged: (vol) {
|
||||
_currentVolume = vol;
|
||||
widget.onVolumeChanged?.call(vol);
|
||||
_emitPlaybackPreferences();
|
||||
},
|
||||
fullscreen: widget.fullscreen,
|
||||
errorMessage: _videoError,
|
||||
@@ -337,7 +361,13 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
void _initializeVideo() {
|
||||
MediaKit.ensureInitialized();
|
||||
_player = Player();
|
||||
_controller = VideoController(_player!);
|
||||
_controller = VideoController(
|
||||
_player!,
|
||||
configuration: VideoControllerConfiguration(
|
||||
enableHardwareAcceleration: !_softwareDecodingFallback,
|
||||
hwdec: _softwareDecodingFallback ? 'no' : 'auto-safe',
|
||||
),
|
||||
);
|
||||
_applyVolume();
|
||||
_player!.setPlaylistMode(
|
||||
_loopVideo ? PlaylistMode.single : PlaylistMode.none,
|
||||
@@ -381,6 +411,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
),
|
||||
play: false,
|
||||
);
|
||||
await _applyVolume();
|
||||
if (initialPosition > Duration.zero) {
|
||||
await player.seek(initialPosition);
|
||||
}
|
||||
@@ -396,18 +427,24 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
await _openVideo(play: play);
|
||||
return;
|
||||
}
|
||||
// If format error occurred on initial load (often due to async referer headers), retry once automatically
|
||||
final lower = message.toLowerCase();
|
||||
// If format or codec error occurred (e.g. unsupported hwdec profile), fallback to software decoding and retry once
|
||||
if (!_retriedFormatError &&
|
||||
(message.contains('Failed to recognize file format') ||
|
||||
message.contains('Demuxer error') ||
|
||||
message.contains('Could not open') ||
|
||||
message.contains('Stream format not recognized') ||
|
||||
message.contains('format unrecognized'))) {
|
||||
(lower.contains('codec') ||
|
||||
lower.contains('could not open') ||
|
||||
lower.contains('couldnt open') ||
|
||||
lower.contains('could not initialize codec') ||
|
||||
lower.contains('demuxer error') ||
|
||||
lower.contains('format unrecognized') ||
|
||||
lower.contains('stream format not recognized') ||
|
||||
lower.contains('failed to recognize file format'))) {
|
||||
_retriedFormatError = true;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
if (mounted && _player != null) {
|
||||
_softwareDecodingFallback = true;
|
||||
_disposeVideo();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||
if (mounted) {
|
||||
_videoIndex = 0;
|
||||
await _openVideo(play: play);
|
||||
_initializeVideo();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -419,7 +456,12 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
Future<void> _retryVideo() async {
|
||||
_videoIndex = 0;
|
||||
_retriedFormatError = false;
|
||||
await _openVideo(play: true);
|
||||
_softwareDecodingFallback = true;
|
||||
_disposeVideo();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 150));
|
||||
if (mounted) {
|
||||
_initializeVideo();
|
||||
}
|
||||
}
|
||||
|
||||
void _disposeVideo() {
|
||||
@@ -529,6 +571,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
final player = _player;
|
||||
final controller = _controller;
|
||||
if (player == null || controller == null) return;
|
||||
final wasPlaying = player.state.playing;
|
||||
setState(() => _inFullscreen = true);
|
||||
if (!context.mounted) return;
|
||||
final result = await Navigator.of(context, rootNavigator: true)
|
||||
@@ -538,6 +581,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
pageBuilder: (_, __, ___) => _FullscreenVideoPage(
|
||||
player: player,
|
||||
controller: controller,
|
||||
wasPlaying: wasPlaying,
|
||||
aspectRatio: widget.post.width > 0 && widget.post.height > 0
|
||||
? widget.post.width / widget.post.height
|
||||
: 16 / 9,
|
||||
@@ -561,6 +605,11 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
if (mounted) setState(() => _inFullscreen = false);
|
||||
if (result != null) {
|
||||
await _applyPlaybackSnapshot(result);
|
||||
if (result.playing && !player.state.playing) {
|
||||
await player.play();
|
||||
} else if (!result.playing && player.state.playing) {
|
||||
await player.pause();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -687,6 +736,7 @@ class _FullscreenVideoPage extends StatefulWidget {
|
||||
const _FullscreenVideoPage({
|
||||
required this.player,
|
||||
required this.controller,
|
||||
required this.wasPlaying,
|
||||
required this.aspectRatio,
|
||||
required this.loopVideo,
|
||||
required this.muted,
|
||||
@@ -700,6 +750,7 @@ class _FullscreenVideoPage extends StatefulWidget {
|
||||
|
||||
final Player player;
|
||||
final VideoController controller;
|
||||
final bool wasPlaying;
|
||||
final double aspectRatio;
|
||||
final bool loopVideo;
|
||||
final bool muted;
|
||||
@@ -732,10 +783,18 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
|
||||
_loopVideo = widget.loopVideo;
|
||||
_halfVolume = widget.halfVolume;
|
||||
_currentVolume = widget.initialVolume;
|
||||
_applyVolume();
|
||||
_isLandscape = widget.aspectRatio > 1.05;
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
_applyOrientation();
|
||||
_scheduleControlsHide();
|
||||
if (widget.wasPlaying) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && !widget.player.state.playing) {
|
||||
widget.player.play();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _applyOrientation() {
|
||||
@@ -996,9 +1055,17 @@ class _VideoSurfaceState extends State<_VideoSurface> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentVolume = widget.player.state.volume > 0
|
||||
? widget.player.state.volume
|
||||
: widget.initialVolume;
|
||||
_currentVolume = widget.initialVolume;
|
||||
widget.player.setVolume(_currentVolume);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _VideoSurface oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.initialVolume != widget.initialVolume) {
|
||||
_currentVolume = widget.initialVolume;
|
||||
widget.player.setVolume(_currentVolume);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1080,6 +1147,7 @@ class _VideoSurfaceState extends State<_VideoSurface> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final player = widget.player;
|
||||
Offset? lastTapDown;
|
||||
|
||||
final child = LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
@@ -1092,6 +1160,32 @@ class _VideoSurfaceState extends State<_VideoSurface> {
|
||||
controller: widget.controller,
|
||||
fit: widget.coverVideo ? BoxFit.cover : BoxFit.contain,
|
||||
controls: null,
|
||||
pauseUponEnteringBackgroundMode: false,
|
||||
resumeUponEnteringForegroundMode: false,
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (details) => lastTapDown = details.localPosition,
|
||||
onTap: widget.onInteract,
|
||||
onDoubleTap: () {
|
||||
if (lastTapDown != null) {
|
||||
_onDoubleTapAt(
|
||||
lastTapDown!,
|
||||
MediaQuery.sizeOf(context).width,
|
||||
);
|
||||
}
|
||||
},
|
||||
onLongPressStart: (_) => _startSpeedBoost(),
|
||||
onLongPressEnd: (_) => _stopSpeedBoost(),
|
||||
onVerticalDragUpdate: (details) {
|
||||
final x = details.localPosition.dx;
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
if (x > width * 0.4) {
|
||||
_adjustVolume(details.primaryDelta ?? 0.0);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
StreamBuilder<bool>(
|
||||
@@ -1148,36 +1242,16 @@ class _VideoSurfaceState extends State<_VideoSurface> {
|
||||
},
|
||||
);
|
||||
|
||||
Offset? lastTapDown;
|
||||
|
||||
return MouseRegion(
|
||||
onHover: (_) => widget.onInteract(),
|
||||
child: GestureDetector(
|
||||
onTapDown: (details) => lastTapDown = details.localPosition,
|
||||
onTap: widget.onInteract,
|
||||
onDoubleTap: () {
|
||||
if (lastTapDown != null) {
|
||||
_onDoubleTapAt(lastTapDown!, MediaQuery.sizeOf(context).width);
|
||||
}
|
||||
},
|
||||
onLongPressStart: (_) => _startSpeedBoost(),
|
||||
onLongPressEnd: (_) => _stopSpeedBoost(),
|
||||
onVerticalDragUpdate: (details) {
|
||||
final x = details.localPosition.dx;
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
if (x > width * 0.4) {
|
||||
_adjustVolume(details.primaryDelta ?? 0.0);
|
||||
}
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(widget.fullscreen ? 0 : 10),
|
||||
child: widget.fullscreen
|
||||
? SizedBox.expand(child: child)
|
||||
: AspectRatio(
|
||||
aspectRatio: widget.aspectRatio.clamp(0.35, 2.4),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(widget.fullscreen ? 0 : 10),
|
||||
child: widget.fullscreen
|
||||
? SizedBox.expand(child: child)
|
||||
: AspectRatio(
|
||||
aspectRatio: widget.aspectRatio.clamp(0.35, 2.4),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1495,30 +1569,38 @@ class _RoundControlButton extends StatelessWidget {
|
||||
: Colors.white;
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: InkResponse(
|
||||
onTap: onPressed,
|
||||
radius: size / 2 + 4,
|
||||
child: AnimatedContainer(
|
||||
duration: AppMotion.duration(context, 140),
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? scheme.primary.withValues(alpha: 0.4)
|
||||
: Colors.white.withValues(alpha: 0.14),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
shape: const CircleBorder(),
|
||||
child: InkResponse(
|
||||
containedInkWell: true,
|
||||
highlightShape: BoxShape.circle,
|
||||
onTap: onPressed,
|
||||
child: AnimatedContainer(
|
||||
duration: AppMotion.duration(context, 140),
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? scheme.primary.withValues(alpha: 0.4)
|
||||
: Colors.white.withValues(alpha: 0.14),
|
||||
),
|
||||
boxShadow: [
|
||||
if (emphasized)
|
||||
BoxShadow(
|
||||
color: scheme.primary.withValues(alpha: 0.45),
|
||||
blurRadius: 28,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(icon, color: foreground, size: iconSize),
|
||||
),
|
||||
boxShadow: [
|
||||
if (emphasized)
|
||||
BoxShadow(
|
||||
color: scheme.primary.withValues(alpha: 0.35),
|
||||
blurRadius: 14,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(icon, color: foreground, size: iconSize),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1918,25 +2000,16 @@ class _VideoControls extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
Container(
|
||||
width: 68,
|
||||
height: 68,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: scheme.primary.withValues(alpha: 0.94),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: scheme.primary.withValues(alpha: 0.45),
|
||||
blurRadius: 28,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: IconButton(
|
||||
tooltip: playing ? 'Пауза' : 'Воспроизведение',
|
||||
iconSize: 42,
|
||||
color: scheme.onPrimary,
|
||||
onPressed: () async {
|
||||
_RoundControlButton(
|
||||
tooltip: playing ? 'Пауза' : 'Воспроизведение',
|
||||
icon: playing
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
size: 68,
|
||||
iconSize: 42,
|
||||
emphasized: true,
|
||||
onPressed: () async {
|
||||
try {
|
||||
if (player.state.playing) {
|
||||
await player.pause();
|
||||
} else {
|
||||
@@ -1945,14 +2018,15 @@ class _VideoControls extends StatelessWidget {
|
||||
}
|
||||
await player.play();
|
||||
}
|
||||
onInteract?.call();
|
||||
},
|
||||
icon: Icon(
|
||||
playing
|
||||
? Icons.pause_rounded
|
||||
: Icons.play_arrow_rounded,
|
||||
),
|
||||
),
|
||||
} catch (_) {
|
||||
if (player.state.playing) {
|
||||
await player.pause();
|
||||
} else {
|
||||
await player.play();
|
||||
}
|
||||
}
|
||||
onInteract?.call();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
_RoundControlButton(
|
||||
|
||||
@@ -596,6 +596,8 @@ class _FeedVideoPreviewState extends State<_FeedVideoPreview> {
|
||||
controller: _controller,
|
||||
fit: BoxFit.cover,
|
||||
controls: null,
|
||||
pauseUponEnteringBackgroundMode: false,
|
||||
resumeUponEnteringForegroundMode: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: gel_rule_app
|
||||
description: Local backend/core layer for a Flutter booru browser app.
|
||||
publish_to: "none"
|
||||
version: 3.1.5+66
|
||||
version: 3.1.8+69
|
||||
|
||||
environment:
|
||||
sdk: ">=3.3.0 <4.0.0"
|
||||
|
||||
Reference in New Issue
Block a user