Compare commits

..
4 Commits
Author SHA1 Message Date
RarDog 8ad36e2f6b fix: eliminate video player blue screen on desktop with software rendering default and decoder toggle (v3.6.4)
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 18:48:33 +03:00
RarDog 4aedde9b3f feat: desktop horizontal mouse-wheel scroll & fix PC video player blue screen (v3.6.3)
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 18:32:45 +03:00
RarDog 5b2c8eb53e fix(perf): memory optimization, prevent OOM on mobile, and completely disable mouse wheel zoom
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 18:09:58 +03:00
RarDog d7d9cad423 fix(desktop): smooth sidebar hover animation without text jitter and remove mouse wheel zoom
Build and Release / Build Windows x64 (push) Has been cancelled
Build and Release / Build macOS (push) Has been cancelled
2026-09-05 17:58:49 +03:00
14 changed files with 1101 additions and 683 deletions
+39 -38
View File
@@ -4,32 +4,33 @@
<application
android:label="Prisma"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:largeHeap="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
@@ -43,15 +44,15 @@
android:resource="@xml/file_paths" />
</provider>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
+38
View File
@@ -1,4 +1,42 @@
const prismaChangelog = [
PrismaChange(
version: '3.6.4',
title: 'Полное исправление воспроизведения видео на ПК (устранение синего экрана)',
bullets: [
'Программный рендеринг (S/W) по умолчанию для ПК: на десктопных платформах отключен проблемный OpenGL FBO мост аппаратного ускорения, вызывавший синий экран из-за отсутствия или конфликтов драйверов VAAPI/VDPAU в Linux/AppImage.',
'Переключатель декодера H/W / S/W в плеере: на верхней панели управления видеоплеером добавлена кнопка быстрого переключения между аппаратным (H/W) и программным (S/W) декодированием на лету.',
'Отображение постера-превью: под видеоплеером теперь отображается превью-постер на время буферизации, исключая появление пустых областей.',
'Безопасное превью в ленте: предпросмотр видео в карточках постов на ПК также переведен на надежный программный рендерер.',
],
),
PrismaChange(
version: '3.6.3',
title: 'Горизонтальный скролл колесиком мыши на ПК и исправление синего экрана в видеоплеере',
bullets: [
'Горизонтальная прокрутка колесиком мыши в настройках: верхняя панель категорий настроек теперь плавно прокручивается колесиком мыши строго при наведении курсора.',
'Горизонтальная прокрутка колесиком мыши в авторах: полоса любимых авторов и лента их медиа теперь поддерживают быструю прокрутку колесиком мыши при наведении курсора.',
'Устранение «синего экрана» в видеоплеере на ПК: исправлена нативная инициализация MediaKit перед запуском Flutter-движка и настроен оптимальный режим аппаратного ускорения для десктопных платформ.',
],
),
PrismaChange(
version: '3.6.2',
title: 'Комплексная оптимизация памяти на телефоне и ПК, полное отключение зума колесиком',
bullets: [
'Полное отключение масштабирования колесиком мыши: перехвачен внутренний обработчик PointerScrollEvent в интерактивном вьюере, что навсегда исключило нежелательный зум при прокрутке.',
'Предотвращение вылетов на смартфонах (OOM): включен флаг android:largeHeap="true", расширяющий доступный лимит оперативной памяти процесса до 512–1024 МБ.',
'Ограничение аппетита кэша изображений: глобальный ImageCache в Flutter ограничен безопасным лимитом в 180 МБ и 120 изображений (вместо неограниченного роста до 1 ГБ+), неактивные растры своевременно выгружаются сборщиком мусора.',
'Оптимизация декодирования тяжелых артов: полноразмерные изображения с экстремальным разрешением (до 10K+) теперь безопасно декодируются с ограничением memCacheWidth под экран устройства, снижая расход RAM на 80–90%.',
'Оптимизация сетки и миниатюр: карточки ленты изолированы в RepaintBoundary для гладкого 120 FPS скролла без паразитных перерисовок, а всем каруселям, аватаркам и мозаикам заданы строгие границы кэширования.',
],
),
PrismaChange(
version: '3.6.1',
title: 'Плавная боковая панель без лагов и отключение зума колесиком',
bullets: [
'Устранение лагов боковой панели: полностью переработана анимация наведения на боковую панель на ПК. Убран тяжелый шейдерный блюр при изменении ширины и зафиксирована верстка элементов — текст больше не сжимается, не дергается и раскрывается на 120 FPS максимально плавно.',
'Отключение зума фото по колесику мыши: исключено случайное и резкое масштабирование изображений при прокрутке колесиком мыши. Масштабирование фото доступно через горячие клавиши (+, -, 0, F) или двойной клик.',
],
),
PrismaChange(
version: '3.6.0',
title: 'Редизайн интерфейса для ПК и система горячих клавиш (хоткеи)',
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -1328,7 +1329,7 @@ class _FavoriteArtistsModalState extends State<_FavoriteArtistsModal> {
}
}
class _FavoriteArtistsSection extends ConsumerWidget {
class _FavoriteArtistsSection extends StatefulWidget {
const _FavoriteArtistsSection({
required this.favorites,
required this.selectedKey,
@@ -1344,8 +1345,23 @@ class _FavoriteArtistsSection extends ConsumerWidget {
final ValueChanged<FavoriteArtistItem> onRemoveFavorite;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (favorites.isEmpty) {
State<_FavoriteArtistsSection> createState() =>
_FavoriteArtistsSectionState();
}
class _FavoriteArtistsSectionState extends State<_FavoriteArtistsSection> {
final ScrollController _scrollController = ScrollController();
bool _isHovered = false;
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.favorites.isEmpty) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: _ArtistsLiquidCard(
@@ -1368,9 +1384,9 @@ class _FavoriteArtistsSection extends ConsumerWidget {
);
}
final selected = favorites.firstWhere(
(f) => f.key == selectedKey,
orElse: () => favorites.first,
final selected = widget.favorites.firstWhere(
(f) => f.key == widget.selectedKey,
orElse: () => widget.favorites.first,
);
return Padding(
@@ -1385,14 +1401,14 @@ class _FavoriteArtistsSection extends ConsumerWidget {
const Icon(Icons.star_rounded, color: Colors.amber, size: 20),
const SizedBox(width: 6),
Text(
'Любимые авторы (${favorites.length})',
'Любимые авторы (${widget.favorites.length})',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
TextButton(
onPressed: () => onOpenArtist(selected),
onPressed: () => widget.onOpenArtist(selected),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -1409,111 +1425,136 @@ class _FavoriteArtistsSection extends ConsumerWidget {
],
),
const SizedBox(height: 8),
SizedBox(
height: 88,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
scrollDirection: Axis.horizontal,
itemCount: favorites.length,
separatorBuilder: (_, __) => const SizedBox(width: 10),
itemBuilder: (context, index) {
final fav = favorites[index];
final isSelected = fav.key == selected.key;
final sColor = _serviceColor(fav.service);
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => onSelect(fav),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context)
.colorScheme
.primaryContainer
.withValues(alpha: 0.45)
: Colors.transparent,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.transparent,
width: 1.5,
),
boxShadow: isSelected
? [
BoxShadow(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.25),
blurRadius: 8,
),
]
: null,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isSelected
? Theme.of(context).colorScheme.primary
: sColor.withValues(alpha: 0.4),
width: 1.2,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(13),
child: fav.avatarUrl != null &&
fav.avatarUrl!.isNotEmpty
? CachedNetworkImage(
imageUrl: fav.avatarUrl!,
fit: BoxFit.cover,
placeholder: (_, __) => const Icon(
Icons.person_rounded,
size: 20),
errorWidget: (_, __, ___) => const Icon(
Icons.person_rounded,
size: 20),
)
: Center(
child: Text(
fav.name.isNotEmpty
? fav.name[0].toUpperCase()
: '?',
style: const TextStyle(
fontWeight: FontWeight.bold),
),
),
),
),
const SizedBox(height: 4),
SizedBox(
width: 68,
child: Text(
fav.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
),
),
),
],
),
),
MouseRegion(
onEnter: (_) => _isHovered = true,
onExit: (_) => _isHovered = false,
child: Listener(
onPointerSignal: (event) {
if (!_isHovered ||
event is! PointerScrollEvent ||
!_scrollController.hasClients) {
return;
}
final delta = event.scrollDelta.dy != 0
? event.scrollDelta.dy
: event.scrollDelta.dx;
if (delta == 0) return;
final target = (_scrollController.offset + delta * 1.5).clamp(
0.0,
_scrollController.position.maxScrollExtent,
);
_scrollController.jumpTo(target);
},
child: SizedBox(
height: 88,
child: ListView.separated(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
scrollDirection: Axis.horizontal,
itemCount: widget.favorites.length,
separatorBuilder: (_, __) => const SizedBox(width: 10),
itemBuilder: (context, index) {
final fav = widget.favorites[index];
final isSelected = fav.key == selected.key;
final sColor = _serviceColor(fav.service);
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => widget.onSelect(fav),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context)
.colorScheme
.primaryContainer
.withValues(alpha: 0.45)
: Colors.transparent,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.transparent,
width: 1.5,
),
boxShadow: isSelected
? [
BoxShadow(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.25),
blurRadius: 8,
),
]
: null,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isSelected
? Theme.of(context).colorScheme.primary
: sColor.withValues(alpha: 0.4),
width: 1.2,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(13),
child: fav.avatarUrl != null &&
fav.avatarUrl!.isNotEmpty
? CachedNetworkImage(
imageUrl: fav.avatarUrl!,
memCacheWidth: 120,
memCacheHeight: 120,
fit: BoxFit.cover,
placeholder: (_, __) => const Icon(
Icons.person_rounded,
size: 20),
errorWidget: (_, __, ___) => const Icon(
Icons.person_rounded,
size: 20),
)
: Center(
child: Text(
fav.name.isNotEmpty
? fav.name[0].toUpperCase()
: '?',
style: const TextStyle(
fontWeight: FontWeight.bold),
),
),
),
),
const SizedBox(height: 4),
SizedBox(
width: 68,
child: Text(
fav.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
),
),
),
],
),
),
);
},
),
),
),
),
const SizedBox(height: 6),
@@ -1525,18 +1566,34 @@ class _FavoriteArtistsSection extends ConsumerWidget {
}
}
class _FavoriteArtistMediaStrip extends ConsumerWidget {
class _FavoriteArtistMediaStrip extends ConsumerStatefulWidget {
const _FavoriteArtistMediaStrip({required this.artist});
final FavoriteArtistItem artist;
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<_FavoriteArtistMediaStrip> createState() =>
_FavoriteArtistMediaStripState();
}
class _FavoriteArtistMediaStripState
extends ConsumerState<_FavoriteArtistMediaStrip> {
final ScrollController _scrollController = ScrollController();
bool _isHovered = false;
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final query = ArtistWorkQuery(
providerId: artist.providerId,
service: artist.service,
artistId: artist.id,
artistName: artist.name,
providerId: widget.artist.providerId,
service: widget.artist.service,
artistId: widget.artist.id,
artistName: widget.artist.name,
);
final asyncPosts = ref.watch(artistPostsProvider(query));
@@ -1564,76 +1621,101 @@ class _FavoriteArtistMediaStrip extends ConsumerWidget {
),
);
}
return SizedBox(
height: 96,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: posts.length.clamp(0, 15),
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final post = posts[index];
final isVideo = MediaUrlSelector.isVideo(post);
return InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => AppNavigator.openPost(
context,
post: post,
postsList: posts,
),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: Colors.white.withValues(alpha: 0.15),
width: 1.0,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(13),
child: Stack(
children: [
SizedBox(
width: 96,
height: 96,
child: CachedNetworkImage(
imageUrl: post.previewUrl.isNotEmpty
? post.previewUrl
: post.sampleUrl,
fit: BoxFit.cover,
placeholder: (_, __) => ColoredBox(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
),
errorWidget: (_, __, ___) => ColoredBox(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
child: const Icon(Icons.broken_image_rounded,
size: 24),
),
),
),
if (isVideo)
Positioned(
bottom: 6,
right: 6,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
shape: BoxShape.circle,
),
child: const Icon(Icons.play_arrow_rounded,
color: Colors.white, size: 14),
),
),
],
),
),
),
return MouseRegion(
onEnter: (_) => _isHovered = true,
onExit: (_) => _isHovered = false,
child: Listener(
onPointerSignal: (event) {
if (!_isHovered ||
event is! PointerScrollEvent ||
!_scrollController.hasClients) {
return;
}
final delta = event.scrollDelta.dy != 0
? event.scrollDelta.dy
: event.scrollDelta.dx;
if (delta == 0) return;
final target = (_scrollController.offset + delta * 1.5).clamp(
0.0,
_scrollController.position.maxScrollExtent,
);
_scrollController.jumpTo(target);
},
child: SizedBox(
height: 96,
child: ListView.separated(
controller: _scrollController,
scrollDirection: Axis.horizontal,
itemCount: posts.length.clamp(0, 15),
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final post = posts[index];
final isVideo = MediaUrlSelector.isVideo(post);
return InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => AppNavigator.openPost(
context,
post: post,
postsList: posts,
),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: Colors.white.withValues(alpha: 0.15),
width: 1.0,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(13),
child: Stack(
children: [
SizedBox(
width: 96,
height: 96,
child: CachedNetworkImage(
imageUrl: post.previewUrl.isNotEmpty
? post.previewUrl
: post.sampleUrl,
memCacheWidth: 200,
memCacheHeight: 200,
fit: BoxFit.cover,
placeholder: (_, __) => ColoredBox(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
),
errorWidget: (_, __, ___) => ColoredBox(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
child: const Icon(Icons.broken_image_rounded,
size: 24),
),
),
),
if (isVideo)
Positioned(
bottom: 6,
right: 6,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
shape: BoxShape.circle,
),
child: const Icon(Icons.play_arrow_rounded,
color: Colors.white, size: 14),
),
),
],
),
),
),
);
},
),
),
),
);
},
@@ -1706,6 +1788,8 @@ class _ArtistAvatar extends StatelessWidget {
? fallback
: CachedNetworkImage(
imageUrl: artist.avatarUrl!,
memCacheWidth: 140,
memCacheHeight: 140,
fit: BoxFit.cover,
placeholder: (_, __) => fallback,
errorWidget: (_, __, ___) => fallback,
@@ -314,6 +314,8 @@ class _CollectionCard extends ConsumerWidget {
if (urls.length == 1) {
return CachedNetworkImage(
imageUrl: urls[0],
memCacheWidth: 260,
memCacheHeight: 260,
fit: BoxFit.cover,
placeholder: (_, __) => Container(color: scheme.surfaceContainerHighest),
errorWidget: (_, __, ___) => Container(
@@ -333,6 +335,8 @@ class _CollectionCard extends ConsumerWidget {
if (i < urls.length)
CachedNetworkImage(
imageUrl: urls[i],
memCacheWidth: 200,
memCacheHeight: 200,
fit: BoxFit.cover,
placeholder: (_, __) =>
Container(color: scheme.surfaceContainerHighest),
@@ -1225,6 +1225,8 @@ class _ArtistCard extends StatelessWidget {
),
child: CachedNetworkImage(
imageUrl: url,
memCacheWidth: 128,
memCacheHeight: 128,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
color: scheme.surfaceContainerHighest,
@@ -1155,6 +1155,8 @@ class _NeighborStrip extends StatelessWidget {
imageUrl: post.previewUrl.isNotEmpty
? post.previewUrl
: post.sampleUrl,
memCacheWidth: 160,
memCacheHeight: 160,
fit: BoxFit.cover,
errorWidget: (_, __, ___) =>
const Center(child: Icon(Icons.broken_image_rounded)),
@@ -78,6 +78,8 @@ class _PostMediaViewerState extends State<PostMediaViewer>
String? _videoError;
bool _retriedFormatError = false;
bool _softwareDecodingFallback = false;
late bool _useSoftwareDecoding =
Platform.isLinux || Platform.isWindows || Platform.isMacOS;
Timer? _hideTimer;
StreamSubscription<String>? _errorSubscription;
StreamSubscription<Duration>? _positionSubscription;
@@ -109,6 +111,8 @@ class _PostMediaViewerState extends State<PostMediaViewer>
_videoError = null;
_retriedFormatError = false;
_softwareDecodingFallback = false;
_useSoftwareDecoding =
Platform.isLinux || Platform.isWindows || Platform.isMacOS;
_imageUrls = _buildImageUrls(widget.post);
_videoUrls = _buildVideoUrls(widget.post);
if (_isPlayableMedia(widget.post) && _videoUrls.isNotEmpty) {
@@ -226,6 +230,11 @@ class _PostMediaViewerState extends State<PostMediaViewer>
aspectRatio: widget.post.width > 0 && widget.post.height > 0
? widget.post.width / widget.post.height
: 16 / 9,
previewUrl: widget.post.previewUrl.isNotEmpty
? widget.post.previewUrl
: widget.post.sampleUrl,
isSoftwareDecoding: _useSoftwareDecoding,
onToggleDecoder: _toggleDecoderMode,
controlsVisible: _controlsVisible,
coverVideo: _coverVideo,
muted: _muted,
@@ -317,10 +326,16 @@ class _PostMediaViewerState extends State<PostMediaViewer>
),
);
} else {
final mq = MediaQuery.maybeOf(context);
final dpr = mq?.devicePixelRatio ?? 1.5;
final screenWidth = mq?.size.width ?? 1280;
final maxCacheWidth =
(screenWidth * dpr * 1.5).round().clamp(1080, 2560);
image = CachedNetworkImage(
key: ValueKey(url),
imageUrl: url,
httpHeaders: headers,
memCacheWidth: maxCacheWidth,
fit: BoxFit.contain,
placeholder: (context, url) =>
const Center(child: CircularProgressIndicator()),
@@ -438,11 +453,12 @@ class _PostMediaViewerState extends State<PostMediaViewer>
void _initializeVideo() {
MediaKit.ensureInitialized();
_player = Player();
final bool enableHw = !_useSoftwareDecoding && !_softwareDecodingFallback;
_controller = VideoController(
_player!,
configuration: VideoControllerConfiguration(
enableHardwareAcceleration: !_softwareDecodingFallback,
hwdec: _softwareDecodingFallback ? 'no' : 'auto-safe',
enableHardwareAcceleration: enableHw,
hwdec: enableHw ? (Platform.isAndroid ? 'auto-safe' : 'auto') : 'no',
),
);
_applyVolume();
@@ -517,6 +533,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
lower.contains('failed to recognize file format'))) {
_retriedFormatError = true;
_softwareDecodingFallback = true;
_useSoftwareDecoding = true;
_disposeVideo();
await Future<void>.delayed(const Duration(milliseconds: 250));
if (mounted) {
@@ -534,6 +551,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
_videoIndex = 0;
_retriedFormatError = false;
_softwareDecodingFallback = true;
_useSoftwareDecoding = true;
_disposeVideo();
await Future<void>.delayed(const Duration(milliseconds: 150));
if (mounted) {
@@ -541,6 +559,26 @@ class _PostMediaViewerState extends State<PostMediaViewer>
}
}
void _toggleDecoderMode() {
setState(() {
_useSoftwareDecoding = !_useSoftwareDecoding;
_softwareDecodingFallback = false;
_retriedFormatError = false;
});
_disposeVideo();
_initializeVideo();
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(
content: Text(
_useSoftwareDecoding
? 'Включено программное декодирование (S/W)'
: 'Включено аппаратное декодирование (H/W)',
),
duration: const Duration(seconds: 2),
),
);
}
void _disposeVideo() {
_hideTimer?.cancel();
_errorSubscription?.cancel();
@@ -682,6 +720,11 @@ class _PostMediaViewerState extends State<PostMediaViewer>
halfVolume: _halfVolume,
coverVideo: _coverVideo,
initialVolume: _currentVolume,
previewUrl: widget.post.previewUrl.isNotEmpty
? widget.post.previewUrl
: widget.post.sampleUrl,
isSoftwareDecoding: _useSoftwareDecoding,
onToggleDecoder: _toggleDecoderMode,
errorMessage: _videoError,
onRetry: _retryVideo,
onChanged: (snapshot) {
@@ -878,6 +921,9 @@ class _FullscreenVideoPage extends StatefulWidget {
required this.halfVolume,
required this.coverVideo,
this.initialVolume = 100.0,
this.previewUrl,
required this.isSoftwareDecoding,
required this.onToggleDecoder,
required this.errorMessage,
required this.onRetry,
required this.onChanged,
@@ -892,6 +938,9 @@ class _FullscreenVideoPage extends StatefulWidget {
final bool halfVolume;
final bool coverVideo;
final double initialVolume;
final String? previewUrl;
final bool isSoftwareDecoding;
final VoidCallback onToggleDecoder;
final String? errorMessage;
final VoidCallback onRetry;
final ValueChanged<VideoPlaybackSnapshot> onChanged;
@@ -1016,6 +1065,9 @@ class _FullscreenVideoPageState extends State<_FullscreenVideoPage> {
player: widget.player,
controller: widget.controller,
aspectRatio: widget.aspectRatio,
previewUrl: widget.previewUrl,
isSoftwareDecoding: widget.isSoftwareDecoding,
onToggleDecoder: widget.onToggleDecoder,
controlsVisible: _controlsVisible,
coverVideo: _coverVideo,
muted: _muted,
@@ -1567,6 +1619,9 @@ class _VideoSurface extends StatefulWidget {
required this.player,
required this.controller,
required this.aspectRatio,
this.previewUrl,
required this.isSoftwareDecoding,
required this.onToggleDecoder,
required this.controlsVisible,
required this.coverVideo,
required this.muted,
@@ -1591,6 +1646,9 @@ class _VideoSurface extends StatefulWidget {
final Player player;
final VideoController controller;
final double aspectRatio;
final String? previewUrl;
final bool isSoftwareDecoding;
final VoidCallback onToggleDecoder;
final bool controlsVisible;
final bool coverVideo;
final bool muted;
@@ -1749,15 +1807,22 @@ class _VideoSurfaceState extends State<_VideoSurface> {
return Stack(
fit: StackFit.expand,
children: [
ColoredBox(
color: Colors.black,
child: Video(
controller: widget.controller,
fit: widget.coverVideo ? BoxFit.cover : BoxFit.contain,
controls: null,
pauseUponEnteringBackgroundMode: false,
resumeUponEnteringForegroundMode: false,
if (widget.previewUrl != null && widget.previewUrl!.isNotEmpty)
Positioned.fill(
child: CachedNetworkImage(
imageUrl: widget.previewUrl!,
fit: widget.coverVideo ? BoxFit.cover : BoxFit.contain,
memCacheWidth: 1920,
memCacheHeight: 1080,
),
),
Video(
controller: widget.controller,
fit: widget.coverVideo ? BoxFit.cover : BoxFit.contain,
fill: Colors.transparent,
controls: null,
pauseUponEnteringBackgroundMode: false,
resumeUponEnteringForegroundMode: false,
),
if (_currentBrightness < 0.99)
Positioned.fill(
@@ -1836,6 +1901,8 @@ class _VideoSurfaceState extends State<_VideoSurface> {
fullscreen: widget.fullscreen,
isLocked: _isLocked,
isLandscape: widget.isLandscape,
isSoftwareDecoding: widget.isSoftwareDecoding,
onToggleDecoder: widget.onToggleDecoder,
onToggleOrientation: widget.onToggleOrientation,
onToggleLock: () => setState(() => _isLocked = !_isLocked),
onToggleFit: widget.onToggleFit,
@@ -2190,29 +2257,6 @@ class _ZoomableImageState extends State<_ZoomableImage> {
)
: widget.child;
return Listener(
onPointerSignal: (signal) {
if (signal is PointerScrollEvent) {
final scaleDelta = signal.scrollDelta.dy < 0 ? 1.15 : 0.85;
final currentScale = _controller.value.getMaxScaleOnAxis();
final newScale = (currentScale * scaleDelta).clamp(1.0, 6.0);
if (newScale <= 1.02) {
_controller.value = Matrix4.identity();
if (mounted) setState(() => _zoomed = false);
_setLocked(false);
} else {
final focalPoint = signal.localPosition;
_controller.value = Matrix4.identity()
..translateByDouble(
-focalPoint.dx * (newScale - 1),
-focalPoint.dy * (newScale - 1),
0,
1)
..scaleByDouble(newScale, newScale, 1, 1);
if (mounted) setState(() => _zoomed = true);
_setLocked(true);
}
}
},
onPointerDown: (_) {
_pointerCount++;
if (_pointerCount >= 2 || _zoomed) {
@@ -2229,6 +2273,7 @@ class _ZoomableImageState extends State<_ZoomableImage> {
transformationController: _controller,
minScale: 1,
maxScale: 6,
scaleFactor: 1000000.0,
boundaryMargin: const EdgeInsets.all(160),
panEnabled: _zoomed,
scaleEnabled: true,
@@ -2245,7 +2290,15 @@ class _ZoomableImageState extends State<_ZoomableImage> {
if (mounted) setState(() => _zoomed = nextZoomed);
_setLocked(nextZoomed || _pointerCount >= 2);
},
child: child,
child: Listener(
onPointerSignal: (event) {
if (event is PointerScrollEvent) {
GestureBinding.instance.pointerSignalResolver
.register(event, (_) {});
}
},
child: child,
),
),
),
);
@@ -2661,6 +2714,8 @@ class _VideoControls extends StatefulWidget {
required this.fullscreen,
required this.isLocked,
this.isLandscape,
required this.isSoftwareDecoding,
required this.onToggleDecoder,
this.onToggleOrientation,
required this.onToggleLock,
required this.onToggleFit,
@@ -2680,6 +2735,8 @@ class _VideoControls extends StatefulWidget {
final bool fullscreen;
final bool isLocked;
final bool? isLandscape;
final bool isSoftwareDecoding;
final VoidCallback onToggleDecoder;
final VoidCallback? onToggleOrientation;
final VoidCallback onToggleLock;
final VoidCallback onToggleFit;
@@ -2779,6 +2836,17 @@ class _VideoControlsState extends State<_VideoControls> {
onPressed: widget.onToggleLock,
),
const Spacer(),
_RoundControlButton(
tooltip: widget.isSoftwareDecoding
? 'Декодер: S/W (программный). Нажмите для переключения на H/W'
: 'Декодер: H/W (аппаратный). Нажмите для переключения на S/W',
selected: !widget.isSoftwareDecoding,
icon: widget.isSoftwareDecoding
? Icons.memory_rounded
: Icons.speed_rounded,
onPressed: widget.onToggleDecoder,
),
const SizedBox(width: 8),
_SpeedBadgeButton(
currentRate: currentRate,
onSelected: (newRate) => widget.player.setRate(newRate),
@@ -1,6 +1,7 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' hide appBuildNumber;
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -1439,7 +1440,7 @@ class _SettingsIconBadge extends StatelessWidget {
}
}
class _CategoryQuickNav extends StatelessWidget {
class _CategoryQuickNav extends StatefulWidget {
const _CategoryQuickNav({
required this.categories,
required this.onSelect,
@@ -1449,103 +1450,143 @@ class _CategoryQuickNav extends StatelessWidget {
categories;
final ValueChanged<GlobalKey> onSelect;
@override
State<_CategoryQuickNav> createState() => _CategoryQuickNavState();
}
class _CategoryQuickNavState extends State<_CategoryQuickNav> {
final ScrollController _scrollController = ScrollController();
bool _isHovered = false;
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return SizedBox(
height: 44,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
scrollDirection: Axis.horizontal,
itemCount: categories.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final cat = categories[index];
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(22),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.25)
: cat.color.withValues(alpha: 0.12),
blurRadius: 8,
offset: const Offset(0, 2.5),
return MouseRegion(
onEnter: (_) => _isHovered = true,
onExit: (_) => _isHovered = false,
child: Listener(
onPointerSignal: (event) {
if (!_isHovered ||
event is! PointerScrollEvent ||
!_scrollController.hasClients) {
return;
}
final delta = event.scrollDelta.dy != 0
? event.scrollDelta.dy
: event.scrollDelta.dx;
if (delta == 0) return;
final target = (_scrollController.offset + delta * 1.5).clamp(
0.0,
_scrollController.position.maxScrollExtent,
);
_scrollController.jumpTo(target);
},
child: SizedBox(
height: 44,
child: ListView.separated(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16),
scrollDirection: Axis.horizontal,
itemCount: widget.categories.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final cat = widget.categories[index];
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(22),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.25)
: cat.color.withValues(alpha: 0.12),
blurRadius: 8,
offset: const Offset(0, 2.5),
),
],
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(22),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
child: Material(
color: isDark
? theme.colorScheme.surfaceContainerHigh.withValues(alpha: 0.60)
: Colors.white.withValues(alpha: 0.82),
child: InkWell(
borderRadius: BorderRadius.circular(22),
onTap: () {
HapticFeedback.selectionClick();
onSelect(cat.key);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
decoration: BoxDecoration(
child: ClipRRect(
borderRadius: BorderRadius.circular(22),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
child: Material(
color: isDark
? theme.colorScheme.surfaceContainerHigh
.withValues(alpha: 0.60)
: Colors.white.withValues(alpha: 0.82),
child: InkWell(
borderRadius: BorderRadius.circular(22),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.18)
: Colors.white.withValues(alpha: 0.85),
width: 1.1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
cat.color,
cat.color.withValues(alpha: 0.82),
],
),
borderRadius: BorderRadius.circular(7),
boxShadow: [
BoxShadow(
color: cat.color.withValues(alpha: 0.35),
blurRadius: 5,
offset: const Offset(0, 1.5),
onTap: () {
HapticFeedback.selectionClick();
widget.onSelect(cat.key);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 7),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(22),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.18)
: Colors.white.withValues(alpha: 0.85),
width: 1.1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
cat.color,
cat.color.withValues(alpha: 0.82),
],
),
borderRadius: BorderRadius.circular(7),
boxShadow: [
BoxShadow(
color: cat.color.withValues(alpha: 0.35),
blurRadius: 5,
offset: const Offset(0, 1.5),
),
],
),
],
),
child: Center(
child: Icon(cat.icon, size: 14, color: Colors.white),
),
child: Center(
child: Icon(cat.icon,
size: 14, color: Colors.white),
),
),
const SizedBox(width: 8),
Text(
cat.label,
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
),
],
),
const SizedBox(width: 8),
Text(
cat.label,
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
),
],
),
),
),
),
),
),
),
);
},
);
},
),
),
),
);
}
@@ -427,6 +427,8 @@ class _ViewedPostTile extends StatelessWidget {
)
: CachedNetworkImage(
imageUrl: image,
memCacheWidth: 320,
memCacheHeight: 320,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
color: scheme.surfaceContainerHighest,
+5
View File
@@ -1,9 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'app/app.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
MediaKit.ensureInitialized();
// Optimize image cache to prevent memory bloat and OOM crashes
PaintingBinding.instance.imageCache.maximumSizeBytes = 180 * 1024 * 1024; // 180 MB
PaintingBinding.instance.imageCache.maximumSize = 120; // 120 images
runApp(const ProviderScope(child: GelRuleApp()));
}
+473 -311
View File
@@ -5,7 +5,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../app/app.dart';
import '../../app/motion.dart';
import '../../app/responsive.dart';
import '../../app/router.dart';
import '../../backend/backend.dart';
@@ -385,10 +384,55 @@ class _DesktopShell extends StatefulWidget {
State<_DesktopShell> createState() => _DesktopShellState();
}
class _DesktopShellState extends State<_DesktopShell> {
class _DesktopShellState extends State<_DesktopShell>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _animation;
bool _hovered = false;
bool _pinned = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 220),
);
_animation = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleHover(bool hovered) {
if (_hovered == hovered) return;
setState(() => _hovered = hovered);
if (_pinned) return;
if (hovered) {
_controller.forward();
} else {
_controller.reverse();
}
}
void _togglePinned() {
setState(() {
_pinned = !_pinned;
if (_pinned) {
_controller.forward();
} else if (!_hovered) {
_controller.reverse();
}
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -400,256 +444,354 @@ class _DesktopShellState extends State<_DesktopShell> {
final selected = widget.destinations.indexWhere(
(item) => AppShell.branchIndexForLocation(item.location) == activeBranch,
);
final isExpanded = _pinned || _hovered;
return Scaffold(
body: Row(
children: [
MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: AppMotion.duration(context, 200),
curve: Curves.easeOutCubic,
width: isExpanded ? 260 : 74,
decoration: BoxDecoration(
color: isDark
? scheme.surfaceContainerHigh.withValues(alpha: 0.75)
: scheme.surface.withValues(alpha: 0.85),
border: Border(
right: BorderSide(
onEnter: (_) => _handleHover(true),
onExit: (_) => _handleHover(false),
child: AnimatedBuilder(
animation: _animation,
builder: (context, _) {
final progress = _animation.value;
final width = lerpDouble(74.0, 260.0, progress)!;
final contentOpacity =
(progress - 0.25).clamp(0.0, 0.75) / 0.75;
final isFullyExpanded = progress > 0.85;
return Container(
width: width,
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.12)
: scheme.outlineVariant.withValues(alpha: 0.35),
width: 1,
? scheme.surfaceContainerHigh
: scheme.surface,
border: Border(
right: BorderSide(
color: isDark
? Colors.white.withValues(alpha: 0.1)
: scheme.outlineVariant.withValues(alpha: 0.35),
width: 1,
),
),
boxShadow: [
BoxShadow(
color:
Colors.black.withValues(alpha: isDark ? 0.3 : 0.05),
blurRadius: 16,
offset: const Offset(3, 0),
),
],
),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.3 : 0.05),
blurRadius: 18,
offset: const Offset(4, 0),
),
],
),
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: SafeArea(
child: Column(
children: [
// App Brand Header
Padding(
padding: const EdgeInsets.fromLTRB(14, 16, 12, 14),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
scheme.primary,
scheme.tertiary,
],
),
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: scheme.primary.withValues(alpha: 0.35),
blurRadius: 12,
offset: const Offset(0, 3),
),
],
),
child: const Center(
child: Icon(
Icons.auto_awesome_rounded,
color: Colors.white,
size: 24,
),
),
),
if (isExpanded) ...[
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Lunaris',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: 0.2,
),
),
const SizedBox(height: 2),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 1.5,
),
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: scheme.primary.withValues(alpha: 0.3),
width: 0.8,
),
),
child: Text(
'v3.6.0',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: scheme.primary,
letterSpacing: 0.4,
),
),
),
],
),
),
IconButton(
visualDensity: VisualDensity.compact,
tooltip: _pinned
? (widget.ru ? 'Открепить панель' : 'Unpin panel')
: (widget.ru ? 'Закрепить панель' : 'Pin panel'),
onPressed: () {
setState(() => _pinned = !_pinned);
},
icon: Icon(
_pinned
? Icons.push_pin_rounded
: Icons.push_pin_outlined,
size: 19,
color: _pinned ? scheme.primary : scheme.onSurfaceVariant,
),
),
],
],
),
),
const Divider(height: 1),
const SizedBox(height: 8),
// Destinations List
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 8),
children: [
for (var index = 0;
index < widget.destinations.length;
index++)
_RailButton(
destination: widget.destinations[index],
ru: widget.ru,
selected: (selected < 0 ? 0 : selected) == index,
expanded: isExpanded,
shortcutKey: 'Ctrl+${index + 1}',
onTap: () {
widget.onNavigate(
widget.destinations[index].location);
},
),
],
),
),
// Bottom Actions
const Divider(height: 1),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 8),
child: ClipRect(
child: OverflowBox(
minWidth: 260,
maxWidth: 260,
alignment: Alignment.topLeft,
child: SizedBox(
width: 260,
child: SafeArea(
child: Column(
children: [
// Shortcuts helper button
Tooltip(
message: isExpanded
? ''
: (widget.ru
? 'Горячие клавиши (F1 / ?)'
: 'Keyboard Shortcuts (F1 / ?)'),
waitDuration: const Duration(milliseconds: 350),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(14),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => DesktopShortcutsDialog.show(context),
child: SizedBox(
height: 44,
child: Row(
children: [
SizedBox(
width: 58,
child: Icon(
Icons.keyboard_rounded,
size: 22,
color: scheme.onSurfaceVariant,
),
// App Brand Header
Padding(
padding:
const EdgeInsets.fromLTRB(15, 16, 12, 14),
child: SizedBox(
height: 44,
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
scheme.primary,
scheme.tertiary,
],
),
if (isExpanded) ...[
Expanded(
child: Text(
widget.ru
? 'Горячие клавиши'
: 'Shortcuts',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
color: scheme.onSurface,
),
overflow: TextOverflow.ellipsis,
),
borderRadius:
BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: scheme.primary
.withValues(alpha: 0.35),
blurRadius: 12,
offset: const Offset(0, 3),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.1)
: Colors.black.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'?',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
),
),
),
const SizedBox(width: 8),
],
],
),
child: const Center(
child: Icon(
Icons.auto_awesome_rounded,
color: Colors.white,
size: 24,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Opacity(
opacity: contentOpacity,
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Text(
'Lunaris',
maxLines: 1,
softWrap: false,
style: theme
.textTheme
.titleMedium
?.copyWith(
fontWeight:
FontWeight.w800,
letterSpacing: 0.2,
),
),
const SizedBox(height: 2),
Container(
padding: const EdgeInsets
.symmetric(
horizontal: 6,
vertical: 1.5,
),
decoration: BoxDecoration(
color: scheme.primary
.withValues(
alpha: 0.15),
borderRadius:
BorderRadius
.circular(6),
border: Border.all(
color: scheme.primary
.withValues(
alpha: 0.3),
width: 0.8,
),
),
child: Text(
'v3.6.4',
style: TextStyle(
fontSize: 10,
fontWeight:
FontWeight.bold,
color: scheme.primary,
letterSpacing: 0.4,
),
),
),
],
),
),
IconButton(
visualDensity:
VisualDensity.compact,
tooltip: _pinned
? (widget.ru
? 'Открепить панель'
: 'Unpin panel')
: (widget.ru
? 'Закрепить панель'
: 'Pin panel'),
onPressed: _togglePinned,
icon: Icon(
_pinned
? Icons.push_pin_rounded
: Icons.push_pin_outlined,
size: 19,
color: _pinned
? scheme.primary
: scheme
.onSurfaceVariant,
),
),
],
),
),
),
],
),
),
),
const Divider(height: 1),
const SizedBox(height: 8),
// Quick pin toggle button when collapsed
if (!isExpanded)
IconButton(
tooltip: widget.ru ? 'Закрепить панель' : 'Pin sidebar',
onPressed: () => setState(() => _pinned = true),
icon: const Icon(Icons.chevron_right_rounded, size: 20),
// Destinations List
Expanded(
child: ListView(
padding:
const EdgeInsets.symmetric(horizontal: 8),
children: [
for (var index = 0;
index < widget.destinations.length;
index++)
_RailButton(
destination: widget.destinations[index],
ru: widget.ru,
selected: (selected < 0 ? 0 : selected) ==
index,
contentOpacity: contentOpacity,
isFullyExpanded: isFullyExpanded,
shortcutKey: 'Ctrl+${index + 1}',
onTap: () {
widget.onNavigate(
widget.destinations[index]
.location);
},
),
],
),
),
// Bottom Actions
const Divider(height: 1),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 8),
child: SizedBox(
width: 244,
child: Row(
children: [
Expanded(
child: Tooltip(
message: isFullyExpanded
? ''
: (widget.ru
? 'Горячие клавиши (F1 / ?)'
: 'Keyboard Shortcuts (F1 / ?)'),
waitDuration: const Duration(
milliseconds: 350),
child: Material(
color: Colors.transparent,
borderRadius:
BorderRadius.circular(14),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () =>
DesktopShortcutsDialog.show(
context),
child: SizedBox(
height: 44,
child: Row(
children: [
SizedBox(
width: 58,
child: Icon(
Icons.keyboard_rounded,
size: 22,
color: scheme
.onSurfaceVariant,
),
),
Expanded(
child: Opacity(
opacity: contentOpacity,
child: Row(
children: [
Expanded(
child: Text(
widget.ru
? 'Горячие клавиши'
: 'Shortcuts',
maxLines: 1,
softWrap: false,
overflow:
TextOverflow
.ellipsis,
style:
TextStyle(
fontWeight:
FontWeight
.w600,
fontSize: 13,
color: scheme
.onSurface,
),
),
),
Container(
padding:
const EdgeInsets
.symmetric(
horizontal: 6,
vertical: 2,
),
decoration:
BoxDecoration(
color: isDark
? Colors
.white
.withValues(
alpha:
0.1)
: Colors
.black
.withValues(
alpha:
0.06),
borderRadius:
BorderRadius
.circular(
6),
),
child: const Text(
'?',
style:
TextStyle(
fontSize: 11,
fontWeight:
FontWeight
.bold,
fontFamily:
'monospace',
),
),
),
const SizedBox(
width: 8),
],
),
),
),
],
),
),
),
),
),
),
if (!_pinned && progress < 0.4)
IconButton(
tooltip: widget.ru
? 'Закрепить панель'
: 'Pin sidebar',
onPressed: _togglePinned,
icon: const Icon(
Icons.chevron_right_rounded,
size: 20),
),
],
),
),
),
],
),
),
],
),
),
),
),
),
);
},
),
),
Expanded(child: widget.child),
@@ -663,7 +805,8 @@ class _RailButton extends StatelessWidget {
const _RailButton({
required this.destination,
required this.selected,
required this.expanded,
required this.contentOpacity,
required this.isFullyExpanded,
required this.ru,
required this.shortcutKey,
required this.onTap,
@@ -671,7 +814,8 @@ class _RailButton extends StatelessWidget {
final _Destination destination;
final bool selected;
final bool expanded;
final double contentOpacity;
final bool isFullyExpanded;
final bool ru;
final String shortcutKey;
final VoidCallback onTap;
@@ -686,7 +830,7 @@ class _RailButton extends StatelessWidget {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Tooltip(
message: expanded ? '' : '$label ($shortcutKey)',
message: isFullyExpanded ? '' : '$label ($shortcutKey)',
waitDuration: const Duration(milliseconds: 350),
child: Material(
color: selected
@@ -696,95 +840,113 @@ class _RailButton extends StatelessWidget {
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Container(
child: SizedBox(
width: 244,
height: 46,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: selected
? Border.all(
color: scheme.primary.withValues(alpha: isDark ? 0.45 : 0.28),
width: 1.1,
)
: null,
),
child: Row(
children: [
// Active left indicator bar
Container(
width: 3.5,
height: 22,
decoration: BoxDecoration(
color: selected ? scheme.primary : Colors.transparent,
borderRadius: BorderRadius.circular(2),
boxShadow: selected
? [
BoxShadow(
color: scheme.primary.withValues(alpha: 0.6),
blurRadius: 6,
),
]
: null,
),
),
SizedBox(
width: 54.5,
child: Center(
child: Icon(
destination.icon,
size: 22,
color: selected
? scheme.primary
: (isDark
? scheme.onSurfaceVariant.withValues(alpha: 0.85)
: scheme.onSurfaceVariant),
),
),
),
if (expanded) ...[
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.5,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected ? scheme.primary : scheme.onSurface,
),
),
),
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: selected
? Border.all(
color: scheme.primary
.withValues(alpha: isDark ? 0.45 : 0.28),
width: 1.1,
)
: null,
),
child: Row(
children: [
// Active left indicator bar
Container(
padding: const EdgeInsets.symmetric(
horizontal: 5.5,
vertical: 2,
),
width: 3.5,
height: 22,
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.12)
: Colors.black.withValues(alpha: 0.08),
width: 0.8,
),
color: selected ? scheme.primary : Colors.transparent,
borderRadius: BorderRadius.circular(2),
boxShadow: selected
? [
BoxShadow(
color: scheme.primary.withValues(alpha: 0.6),
blurRadius: 6,
),
]
: null,
),
child: Text(
shortcutKey,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
),
SizedBox(
width: 54.5,
child: Center(
child: Icon(
destination.icon,
size: 22,
color: selected
? scheme.primary
: scheme.onSurfaceVariant.withValues(alpha: 0.8),
: (isDark
? scheme.onSurfaceVariant
.withValues(alpha: 0.85)
: scheme.onSurfaceVariant),
),
),
),
Expanded(
child: Opacity(
opacity: contentOpacity,
child: Row(
children: [
Expanded(
child: Text(
label,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.5,
fontWeight: selected
? FontWeight.w700
: FontWeight.w500,
color: selected
? scheme.primary
: scheme.onSurface,
),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 5.5,
vertical: 2,
),
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.12)
: Colors.black.withValues(alpha: 0.08),
width: 0.8,
),
),
child: Text(
shortcutKey,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
color: selected
? scheme.primary
: scheme.onSurfaceVariant
.withValues(alpha: 0.8),
),
),
),
const SizedBox(width: 8),
],
),
),
),
const SizedBox(width: 10),
],
],
),
),
),
),
+18 -11
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
@@ -232,7 +233,7 @@ class _PostCardState extends ConsumerState<PostCard>
}
final dpr = MediaQuery.devicePixelRatioOf(context);
final cacheWidth =
(constraints.maxWidth * dpr).round().clamp(320, 1800);
(constraints.maxWidth * dpr).round().clamp(280, 960);
final headers = _headersFor(post);
final videoUrls = MediaUrlSelector.video(post);
if (!mobile &&
@@ -590,7 +591,15 @@ class _FeedVideoPreviewState extends State<_FeedVideoPreview> {
super.initState();
MediaKit.ensureInitialized();
_player = Player();
_controller = VideoController(_player);
final isDesktop =
Platform.isLinux || Platform.isWindows || Platform.isMacOS;
_controller = VideoController(
_player,
configuration: VideoControllerConfiguration(
enableHardwareAcceleration: !isDesktop,
hwdec: isDesktop ? 'no' : 'auto-safe',
),
);
_player.stream.error.listen((_) {
if (mounted) setState(() => _failed = true);
});
@@ -633,15 +642,13 @@ class _FeedVideoPreviewState extends State<_FeedVideoPreview> {
AnimatedOpacity(
opacity: _ready ? 1 : 0,
duration: AppMotion.duration(context, 180),
child: ColoredBox(
color: Colors.black,
child: Video(
controller: _controller,
fit: BoxFit.cover,
controls: null,
pauseUponEnteringBackgroundMode: false,
resumeUponEnteringForegroundMode: false,
),
child: Video(
controller: _controller,
fit: BoxFit.cover,
fill: Colors.transparent,
controls: null,
pauseUponEnteringBackgroundMode: false,
resumeUponEnteringForegroundMode: false,
),
),
],
+20 -18
View File
@@ -68,24 +68,26 @@ class PostMasonryGrid extends StatelessWidget {
final spacing = mobile ? 8.0 : 12.0;
Widget buildCard(Post post) {
return PostCard(
post: post,
blurExplicit: blurExplicit && !nsfwEnabled,
showBadges: showBadges,
isFavorite: favoriteKeys.contains(post.cacheKey),
isViewed: viewedKeys.contains(post.cacheKey),
isDownloaded: downloadedKeys.contains(post.cacheKey),
mediaQualityMode: mediaQualityMode,
onOpen: () => onOpen(post),
onFavorite: () => onFavorite(post),
onPreview: onPreview == null ? null : () => onPreview!(post),
onHide: onHide == null ? null : () => onHide!(post),
selectionMode: selectionMode,
selected: selectedKeys.contains(post.cacheKey),
onToggleSelected:
onToggleSelected == null ? null : () => onToggleSelected!(post),
onAddToCollection:
onAddToCollection == null ? null : () => onAddToCollection!(post),
return RepaintBoundary(
child: PostCard(
post: post,
blurExplicit: blurExplicit && !nsfwEnabled,
showBadges: showBadges,
isFavorite: favoriteKeys.contains(post.cacheKey),
isViewed: viewedKeys.contains(post.cacheKey),
isDownloaded: downloadedKeys.contains(post.cacheKey),
mediaQualityMode: mediaQualityMode,
onOpen: () => onOpen(post),
onFavorite: () => onFavorite(post),
onPreview: onPreview == null ? null : () => onPreview!(post),
onHide: onHide == null ? null : () => onHide!(post),
selectionMode: selectionMode,
selected: selectedKeys.contains(post.cacheKey),
onToggleSelected:
onToggleSelected == null ? null : () => onToggleSelected!(post),
onAddToCollection:
onAddToCollection == null ? null : () => onAddToCollection!(post),
),
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none"
version: 3.6.0+82
version: 3.6.4+86
environment:
sdk: ">=3.3.0 <4.0.0"