feat: add audio playback support (MP3/WAV/FLAC) and fix attachment loading in creator posts
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/RarDog/Prisma/releases"><img src="https://img.shields.io/badge/Version-3.5.2-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.5.2" /></a>
|
||||
<a href="https://github.com/RarDog/Prisma/releases"><img src="https://img.shields.io/badge/Version-3.5.3-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.5.3" /></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%20%7C%20macOS-00C853?style=for-the-badge" alt="Platforms" />
|
||||
<a href="https://github.com/RarDog/Prisma/wiki"><img src="https://img.shields.io/badge/Wiki-Documentation-FF6F00?style=for-the-badge&logo=gitbook&logoColor=white" alt="Wiki" /></a>
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/RarDog/Prisma/releases"><img src="https://img.shields.io/badge/Version-3.5.2-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.5.2" /></a>
|
||||
<a href="https://github.com/RarDog/Prisma/releases"><img src="https://img.shields.io/badge/Version-3.5.3-8A2BE2?style=for-the-badge&logo=semver&logoColor=white" alt="Version 3.5.3" /></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%20%7C%20macOS-00C853?style=for-the-badge" alt="Platforms" />
|
||||
<a href="https://github.com/RarDog/Prisma/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.5.2';
|
||||
const appBuildNumber = 78;
|
||||
const appDisplayVersion = '3.5.3';
|
||||
const appBuildNumber = 79;
|
||||
const appReleaseChannel = 'Stable';
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
const prismaChangelog = [
|
||||
PrismaChange(
|
||||
version: '3.5.3',
|
||||
title: 'Поддержка аудиозаписей (MP3/WAV/FLAC) и исправление загрузки медиа у авторов',
|
||||
bullets: [
|
||||
'Полноценная поддержка аудио: добавлено воспроизведение MP3, M4A, WAV, FLAC, OGG, AAC и OPUS файлов авторов (Patreon, Fanbox и др.).',
|
||||
'Встроенный аудиопроигрыватель: карточка с анимированной пластинкой/обложкой, таймлайном, перемоткой на 10 сек, повтором и регулятором громкости.',
|
||||
'Устранение ошибки 404 у миниатюр: исправлена попытка загрузки несуществующих эскизов для аудио и видео с Pawchive, из-за чего файлы отображались как битые.',
|
||||
'Фильтр аудиозаписей: во вкладке артиста добавлен фильтр «Аудио», позволяющий мгновенно находить все треки и голосовые посты автора.',
|
||||
],
|
||||
),
|
||||
PrismaChange(
|
||||
version: '3.5.2',
|
||||
title: 'Исправление наложения клавиатуры и поиска авторов',
|
||||
|
||||
@@ -600,14 +600,47 @@ class KemonoProvider
|
||||
}
|
||||
|
||||
String _fileType(String value) {
|
||||
final lower = value.toLowerCase().split('?').first;
|
||||
if (lower.endsWith('.webm') || lower.endsWith('.mp4')) return 'video';
|
||||
final lower = value.toLowerCase().split('?').first.trim();
|
||||
if (lower.endsWith('.webm') ||
|
||||
lower.endsWith('.mp4') ||
|
||||
lower.endsWith('.mov') ||
|
||||
lower.endsWith('.mkv') ||
|
||||
lower.endsWith('.avi') ||
|
||||
lower.endsWith('.flv') ||
|
||||
lower.endsWith('.wmv') ||
|
||||
lower.endsWith('.m4v') ||
|
||||
lower.endsWith('.ts')) {
|
||||
return 'video';
|
||||
}
|
||||
if (lower.endsWith('.mp3') ||
|
||||
lower.endsWith('.m4a') ||
|
||||
lower.endsWith('.wav') ||
|
||||
lower.endsWith('.ogg') ||
|
||||
lower.endsWith('.flac') ||
|
||||
lower.endsWith('.aac') ||
|
||||
lower.endsWith('.opus') ||
|
||||
lower.endsWith('.wma')) {
|
||||
return 'audio';
|
||||
}
|
||||
if (lower.endsWith('.gif')) return 'gif';
|
||||
if (lower.endsWith('.swf')) return 'swf';
|
||||
if (lower.endsWith('.png')) return 'png';
|
||||
if (lower.endsWith('.jpg') ||
|
||||
if (lower.endsWith('.zip') ||
|
||||
lower.endsWith('.rar') ||
|
||||
lower.endsWith('.7z') ||
|
||||
lower.endsWith('.tar') ||
|
||||
lower.endsWith('.gz') ||
|
||||
lower.endsWith('.pdf') ||
|
||||
lower.endsWith('.txt')) {
|
||||
return 'archive';
|
||||
}
|
||||
if (lower.endsWith('.png') ||
|
||||
lower.endsWith('.jpg') ||
|
||||
lower.endsWith('.jpeg') ||
|
||||
lower.endsWith('.webp')) {
|
||||
lower.endsWith('.webp') ||
|
||||
lower.endsWith('.avif') ||
|
||||
lower.endsWith('.bmp') ||
|
||||
lower.endsWith('.heic') ||
|
||||
lower.endsWith('.tiff')) {
|
||||
return 'photo';
|
||||
}
|
||||
return 'unknown';
|
||||
|
||||
@@ -414,7 +414,15 @@ class PawchiveProvider
|
||||
}
|
||||
final attachments = json['attachments'];
|
||||
if (attachments is List) {
|
||||
files.addAll(attachments.whereType<Map>().map(Map<String, dynamic>.from));
|
||||
for (final att in attachments.whereType<Map>()) {
|
||||
final m = Map<String, dynamic>.from(att);
|
||||
final path = (m['path'] ?? '').toString();
|
||||
if (path.isNotEmpty &&
|
||||
files.any((existing) => (existing['path'] ?? '').toString() == path)) {
|
||||
continue;
|
||||
}
|
||||
files.add(m);
|
||||
}
|
||||
}
|
||||
|
||||
// Support link-only posts where author uploaded files to cloud drives (MEGA, GDrive, etc.)
|
||||
@@ -468,6 +476,17 @@ class PawchiveProvider
|
||||
];
|
||||
}
|
||||
|
||||
// Determine if there is a cover illustration for the post
|
||||
String? postCoverUrl;
|
||||
for (final f in files) {
|
||||
final p = (f['path'] ?? '').toString();
|
||||
final n = (f['name'] ?? '').toString();
|
||||
if (_isPhotoType('$n $p') && p.isNotEmpty) {
|
||||
postCoverUrl = _thumbnailUrl(p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var index = 0;
|
||||
return files
|
||||
.map((fileMap) {
|
||||
@@ -475,18 +494,26 @@ class PawchiveProvider
|
||||
final fileName = (fileMap['name'] ?? '').toString();
|
||||
final previewOnly = fileMap['preview_only'] == true;
|
||||
|
||||
final previewUrl = _thumbnailUrl(rawPath);
|
||||
final type = _fileType('$fileName $rawPath');
|
||||
final isPhoto = _isPhotoType('$fileName $rawPath');
|
||||
|
||||
// img.pawchive.pw/thumbnail only generates thumbnails for images/gifs.
|
||||
// Non-image files (audio, video, archive) return 404 from thumbnail server.
|
||||
final previewUrl = isPhoto
|
||||
? _thumbnailUrl(rawPath)
|
||||
: (postCoverUrl ?? '');
|
||||
|
||||
final fileUrl = previewOnly
|
||||
? previewUrl
|
||||
? (isPhoto ? previewUrl : _fileDownloadUrl(rawPath, fileName))
|
||||
: _fileDownloadUrl(rawPath, fileName);
|
||||
|
||||
final type = _fileType('$fileName $rawPath');
|
||||
final stableId =
|
||||
'${query.service}:${query.artistId}:$postId:${index++}';
|
||||
final tags = [
|
||||
query.service,
|
||||
query.artistName,
|
||||
if (title.trim().isNotEmpty) title.trim(),
|
||||
if (type == 'audio') 'audio',
|
||||
];
|
||||
|
||||
return Post(
|
||||
@@ -600,24 +627,57 @@ class PawchiveProvider
|
||||
}
|
||||
|
||||
String _fileType(String value) {
|
||||
final lower = value.toLowerCase().split('?').first;
|
||||
if (lower.endsWith('.webm') || lower.endsWith('.mp4') || lower.endsWith('.mov')) {
|
||||
final lower = value.toLowerCase().split('?').first.trim();
|
||||
if (lower.endsWith('.webm') ||
|
||||
lower.endsWith('.mp4') ||
|
||||
lower.endsWith('.mov') ||
|
||||
lower.endsWith('.mkv') ||
|
||||
lower.endsWith('.avi') ||
|
||||
lower.endsWith('.flv') ||
|
||||
lower.endsWith('.wmv') ||
|
||||
lower.endsWith('.m4v') ||
|
||||
lower.endsWith('.ts')) {
|
||||
return 'video';
|
||||
}
|
||||
if (lower.endsWith('.mp3') ||
|
||||
lower.endsWith('.m4a') ||
|
||||
lower.endsWith('.wav') ||
|
||||
lower.endsWith('.ogg') ||
|
||||
lower.endsWith('.flac') ||
|
||||
lower.endsWith('.aac') ||
|
||||
lower.endsWith('.opus') ||
|
||||
lower.endsWith('.wma')) {
|
||||
return 'audio';
|
||||
}
|
||||
if (lower.endsWith('.gif')) return 'gif';
|
||||
if (lower.endsWith('.swf')) return 'swf';
|
||||
if (lower.endsWith('.zip') || lower.endsWith('.rar') || lower.endsWith('.7z')) {
|
||||
if (lower.endsWith('.zip') ||
|
||||
lower.endsWith('.rar') ||
|
||||
lower.endsWith('.7z') ||
|
||||
lower.endsWith('.tar') ||
|
||||
lower.endsWith('.gz') ||
|
||||
lower.endsWith('.pdf') ||
|
||||
lower.endsWith('.txt')) {
|
||||
return 'archive';
|
||||
}
|
||||
if (lower.endsWith('.png') ||
|
||||
lower.endsWith('.jpg') ||
|
||||
lower.endsWith('.jpeg') ||
|
||||
lower.endsWith('.webp')) {
|
||||
lower.endsWith('.webp') ||
|
||||
lower.endsWith('.avif') ||
|
||||
lower.endsWith('.bmp') ||
|
||||
lower.endsWith('.heic') ||
|
||||
lower.endsWith('.tiff')) {
|
||||
return 'photo';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
bool _isPhotoType(String value) {
|
||||
final type = _fileType(value);
|
||||
return type == 'photo' || type == 'gif';
|
||||
}
|
||||
|
||||
DateTime? _dateFromAny(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is num) {
|
||||
|
||||
@@ -80,7 +80,16 @@ class MediaUrlSelector {
|
||||
]);
|
||||
}
|
||||
|
||||
static List<String> audio(Post post) {
|
||||
return _compact([
|
||||
post.fileUrl,
|
||||
if (_looksLikeAudioUrl(post.sampleUrl)) post.sampleUrl,
|
||||
if (_looksLikeAudioUrl(post.previewUrl)) post.previewUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
static bool isVideo(Post post) => _isVideo(post);
|
||||
static bool isAudio(Post post) => _isAudio(post);
|
||||
static bool isGif(Post post) => _isGif(post);
|
||||
|
||||
static List<String> _compact(Iterable<String> urls) {
|
||||
@@ -91,12 +100,32 @@ class MediaUrlSelector {
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
static bool _isAudio(Post post) {
|
||||
final value = '${post.fileType} ${post.fileUrl}'.toLowerCase();
|
||||
return value.contains('audio') ||
|
||||
value.contains('.mp3') ||
|
||||
value.contains('.m4a') ||
|
||||
value.contains('.wav') ||
|
||||
value.contains('.ogg') ||
|
||||
value.contains('.flac') ||
|
||||
value.contains('.aac') ||
|
||||
value.contains('.opus') ||
|
||||
value.contains('.wma');
|
||||
}
|
||||
|
||||
static bool _isVideo(Post post) {
|
||||
if (_isAudio(post)) return false;
|
||||
final value = '${post.fileType} ${post.fileUrl}'.toLowerCase();
|
||||
return value.contains('video') ||
|
||||
value.contains('.webm') ||
|
||||
value.contains('.mp4') ||
|
||||
value.contains('.mov');
|
||||
value.contains('.mov') ||
|
||||
value.contains('.mkv') ||
|
||||
value.contains('.avi') ||
|
||||
value.contains('.flv') ||
|
||||
value.contains('.wmv') ||
|
||||
value.contains('.m4v') ||
|
||||
value.contains('.ts');
|
||||
}
|
||||
|
||||
static bool _isGif(Post post) {
|
||||
@@ -108,6 +137,24 @@ class MediaUrlSelector {
|
||||
final value = url.toLowerCase();
|
||||
return value.contains('.webm') ||
|
||||
value.contains('.mp4') ||
|
||||
value.contains('.mov');
|
||||
value.contains('.mov') ||
|
||||
value.contains('.mkv') ||
|
||||
value.contains('.avi') ||
|
||||
value.contains('.flv') ||
|
||||
value.contains('.wmv') ||
|
||||
value.contains('.m4v') ||
|
||||
value.contains('.ts');
|
||||
}
|
||||
|
||||
static bool _looksLikeAudioUrl(String url) {
|
||||
final value = url.toLowerCase();
|
||||
return value.contains('.mp3') ||
|
||||
value.contains('.m4a') ||
|
||||
value.contains('.wav') ||
|
||||
value.contains('.ogg') ||
|
||||
value.contains('.flac') ||
|
||||
value.contains('.aac') ||
|
||||
value.contains('.opus') ||
|
||||
value.contains('.wma');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,11 +191,13 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
bool _matchesTypeFilter(Post post) {
|
||||
if (_selectedTypes.isEmpty) return true;
|
||||
final isVid = MediaUrlSelector.isVideo(post);
|
||||
final isAud = MediaUrlSelector.isAudio(post);
|
||||
final isGif = MediaUrlSelector.isGif(post);
|
||||
final isText = post.fileType == 'text';
|
||||
final isPhoto = !isVid && !isGif && !isText;
|
||||
final isPhoto = !isVid && !isAud && !isGif && !isText;
|
||||
|
||||
if (_selectedTypes.contains('video') && isVid) return true;
|
||||
if (_selectedTypes.contains('audio') && isAud) return true;
|
||||
if (_selectedTypes.contains('gif') && isGif) return true;
|
||||
if (_selectedTypes.contains('photo') && isPhoto) return true;
|
||||
if (_selectedTypes.contains('text') && isText) return true;
|
||||
@@ -462,10 +464,12 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
|
||||
Widget _buildMediaTypeFilters() {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final audioCount = _posts.where(MediaUrlSelector.isAudio).length;
|
||||
final photoCount = _posts
|
||||
.where((p) =>
|
||||
p.fileType != 'text' &&
|
||||
!MediaUrlSelector.isVideo(p) &&
|
||||
!MediaUrlSelector.isAudio(p) &&
|
||||
!MediaUrlSelector.isGif(p))
|
||||
.length;
|
||||
final videoCount = _posts.where(MediaUrlSelector.isVideo).length;
|
||||
@@ -489,8 +493,8 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
avatar: Icon(Icons.photo_outlined,
|
||||
size: 16,
|
||||
color: _selectedTypes.contains('photo')
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant),
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant),
|
||||
label: Text('Фото ($photoCount)'),
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
@@ -508,8 +512,8 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
avatar: Icon(Icons.videocam_outlined,
|
||||
size: 16,
|
||||
color: _selectedTypes.contains('video')
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant),
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant),
|
||||
label: Text('Видео ($videoCount)'),
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
@@ -521,14 +525,35 @@ class _ArtistPostsScreenState extends ConsumerState<ArtistPostsScreen> {
|
||||
});
|
||||
},
|
||||
),
|
||||
if (audioCount > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
selected: _selectedTypes.contains('audio'),
|
||||
avatar: Icon(Icons.audiotrack_rounded,
|
||||
size: 16,
|
||||
color: _selectedTypes.contains('audio')
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant),
|
||||
label: Text('Аудио ($audioCount)'),
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedTypes.add('audio');
|
||||
} else {
|
||||
_selectedTypes.remove('audio');
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
selected: _selectedTypes.contains('gif'),
|
||||
avatar: Icon(Icons.gif_rounded,
|
||||
size: 20,
|
||||
color: _selectedTypes.contains('gif')
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant),
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant),
|
||||
label: Text('GIF ($gifCount)'),
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
|
||||
@@ -114,7 +114,8 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
(post.previewUrl.isEmpty &&
|
||||
post.sampleUrl.isEmpty &&
|
||||
post.fileUrl.isEmpty &&
|
||||
!MediaUrlSelector.isVideo(post));
|
||||
!MediaUrlSelector.isVideo(post) &&
|
||||
!MediaUrlSelector.isAudio(post));
|
||||
final shouldShowCloudCard = isTextOnly
|
||||
? post.cloudLinks.isNotEmpty
|
||||
: (post.cloudLinks.isNotEmpty ||
|
||||
@@ -230,17 +231,24 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: MediaUrlSelector.isVideo(post)
|
||||
child: (MediaUrlSelector.isVideo(post) ||
|
||||
MediaUrlSelector.isAudio(post))
|
||||
? ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: 760,
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaUrlSelector.isAudio(post)
|
||||
? 360
|
||||
: 760,
|
||||
maxWidth: MediaUrlSelector.isAudio(post)
|
||||
? 620
|
||||
: double.infinity,
|
||||
),
|
||||
child: AspectRatio(
|
||||
aspectRatio:
|
||||
(post.width > 0 && post.height > 0)
|
||||
aspectRatio: MediaUrlSelector.isAudio(post)
|
||||
? 1.4
|
||||
: ((post.width > 0 && post.height > 0)
|
||||
? (post.width / post.height)
|
||||
.clamp(0.45, 2.4)
|
||||
: (16 / 9),
|
||||
: (16 / 9)),
|
||||
child: PostMediaViewer(
|
||||
key: ValueKey(post.cacheKey),
|
||||
post: post,
|
||||
@@ -433,6 +441,7 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
List<Post>? feedPosts,
|
||||
]) {
|
||||
final isVideo = MediaUrlSelector.isVideo(post);
|
||||
final isAudio = MediaUrlSelector.isAudio(post);
|
||||
final localMedia =
|
||||
ref.watch(downloadedMediaByKeyProvider(post.cacheKey)).value;
|
||||
final fileSizeBytes = localMedia != null
|
||||
@@ -450,7 +459,8 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
(post.previewUrl.isEmpty &&
|
||||
post.sampleUrl.isEmpty &&
|
||||
post.fileUrl.isEmpty &&
|
||||
!MediaUrlSelector.isVideo(post));
|
||||
!MediaUrlSelector.isVideo(post) &&
|
||||
!MediaUrlSelector.isAudio(post));
|
||||
final shouldShowCloudCard = isTextOnly
|
||||
? post.cloudLinks.isNotEmpty
|
||||
: (post.cloudLinks.isNotEmpty ||
|
||||
@@ -479,16 +489,20 @@ class PostDetailsScreen extends ConsumerWidget {
|
||||
? null
|
||||
: () =>
|
||||
_showMobileQuickActions(context, ref, post, favoriteKeys),
|
||||
child: isVideo
|
||||
child: (isVideo || isAudio)
|
||||
? Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.70,
|
||||
maxHeight: isAudio
|
||||
? 340
|
||||
: MediaQuery.sizeOf(context).height * 0.70,
|
||||
),
|
||||
child: AspectRatio(
|
||||
aspectRatio: (post.width > 0 && post.height > 0)
|
||||
? (post.width / post.height).clamp(0.45, 2.4)
|
||||
: (16 / 9),
|
||||
aspectRatio: isAudio
|
||||
? 1.3
|
||||
: ((post.width > 0 && post.height > 0)
|
||||
? (post.width / post.height).clamp(0.45, 2.4)
|
||||
: (16 / 9)),
|
||||
child: PostMediaViewer(
|
||||
key: ValueKey(post.cacheKey),
|
||||
post: post,
|
||||
|
||||
@@ -94,7 +94,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
_currentVolume = widget.initialVolume;
|
||||
_imageUrls = _buildImageUrls(widget.post);
|
||||
_videoUrls = _buildVideoUrls(widget.post);
|
||||
if (_isVideo(widget.post) && _videoUrls.isNotEmpty) {
|
||||
if (_isPlayableMedia(widget.post) && _videoUrls.isNotEmpty) {
|
||||
_initializeVideo();
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
_softwareDecodingFallback = false;
|
||||
_imageUrls = _buildImageUrls(widget.post);
|
||||
_videoUrls = _buildVideoUrls(widget.post);
|
||||
if (_isVideo(widget.post) && _videoUrls.isNotEmpty) {
|
||||
if (_isPlayableMedia(widget.post) && _videoUrls.isNotEmpty) {
|
||||
_initializeVideo();
|
||||
}
|
||||
} else if (oldWidget.mediaHeaders != widget.mediaHeaders) {
|
||||
@@ -160,7 +160,38 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
return const _UnsupportedSwfPanel();
|
||||
}
|
||||
|
||||
if (_controller != null) {
|
||||
if (_controller != null || _player != null) {
|
||||
if (_isAudio(widget.post) && _player != null) {
|
||||
return _AudioSurface(
|
||||
player: _player!,
|
||||
post: widget.post,
|
||||
muted: _muted,
|
||||
loopAudio: _loopVideo,
|
||||
initialVolume: _currentVolume,
|
||||
onVolumeChanged: (vol) {
|
||||
_currentVolume = vol;
|
||||
widget.onVolumeChanged?.call(vol);
|
||||
_emitPlaybackPreferences();
|
||||
},
|
||||
fullscreen: widget.fullscreen,
|
||||
errorMessage: _videoError,
|
||||
onRetry: _retryVideo,
|
||||
onToggleMute: () async {
|
||||
final nextMuted = !_muted;
|
||||
setState(() => _muted = nextMuted);
|
||||
await _applyVolume();
|
||||
_emitPlaybackPreferences();
|
||||
},
|
||||
onToggleLoop: () async {
|
||||
final nextLoop = !_loopVideo;
|
||||
setState(() => _loopVideo = nextLoop);
|
||||
await _player!.setPlaylistMode(
|
||||
nextLoop ? PlaylistMode.single : PlaylistMode.none,
|
||||
);
|
||||
_emitPlaybackPreferences();
|
||||
},
|
||||
);
|
||||
}
|
||||
if (_inFullscreen) {
|
||||
return ColoredBox(
|
||||
color: Colors.black,
|
||||
@@ -495,7 +526,9 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
}
|
||||
|
||||
List<String> _buildVideoUrls(Post post) {
|
||||
final list = List<String>.from(MediaUrlSelector.video(post));
|
||||
final list = _isAudio(post)
|
||||
? List<String>.from(MediaUrlSelector.audio(post))
|
||||
: List<String>.from(MediaUrlSelector.video(post));
|
||||
final cloudStreams = post.cloudLinks
|
||||
.where((l) => l.isStreamable && l.directStreamUrl != null)
|
||||
.map((l) => l.directStreamUrl!);
|
||||
@@ -509,15 +542,16 @@ class _PostMediaViewerState extends State<PostMediaViewer>
|
||||
return list;
|
||||
}
|
||||
|
||||
bool _isAudio(Post post) => MediaUrlSelector.isAudio(post);
|
||||
|
||||
bool _isVideo(Post post) {
|
||||
if (_isAudio(post)) return false;
|
||||
if (post.cloudLinks.any((l) => l.isStreamable)) return true;
|
||||
final value = '${post.fileType} ${post.fileUrl}'.toLowerCase();
|
||||
return value.contains('video') ||
|
||||
value.contains('.webm') ||
|
||||
value.contains('.mp4') ||
|
||||
value.contains('.mov');
|
||||
return MediaUrlSelector.isVideo(post);
|
||||
}
|
||||
|
||||
bool _isPlayableMedia(Post post) => _isVideo(post) || _isAudio(post);
|
||||
|
||||
bool _isSwf(Post post) {
|
||||
final value =
|
||||
'${post.fileType} ${post.fileUrl} ${post.sampleUrl} ${post.source ?? ''}'
|
||||
@@ -997,6 +1031,455 @@ class _CloseVideoIntent extends Intent {
|
||||
const _CloseVideoIntent();
|
||||
}
|
||||
|
||||
class _AudioSurface extends StatefulWidget {
|
||||
const _AudioSurface({
|
||||
required this.player,
|
||||
required this.post,
|
||||
required this.muted,
|
||||
required this.loopAudio,
|
||||
this.initialVolume = 100.0,
|
||||
this.onVolumeChanged,
|
||||
required this.fullscreen,
|
||||
required this.errorMessage,
|
||||
required this.onRetry,
|
||||
required this.onToggleMute,
|
||||
required this.onToggleLoop,
|
||||
});
|
||||
|
||||
final Player player;
|
||||
final Post post;
|
||||
final bool muted;
|
||||
final bool loopAudio;
|
||||
final double initialVolume;
|
||||
final ValueChanged<double>? onVolumeChanged;
|
||||
final bool fullscreen;
|
||||
final String? errorMessage;
|
||||
final VoidCallback onRetry;
|
||||
final VoidCallback onToggleMute;
|
||||
final VoidCallback onToggleLoop;
|
||||
|
||||
@override
|
||||
State<_AudioSurface> createState() => _AudioSurfaceState();
|
||||
}
|
||||
|
||||
class _AudioSurfaceState extends State<_AudioSurface>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _rotationController;
|
||||
StreamSubscription<Duration>? _posSub;
|
||||
StreamSubscription<Duration>? _durSub;
|
||||
StreamSubscription<bool>? _playSub;
|
||||
StreamSubscription<bool>? _buffSub;
|
||||
|
||||
Duration _position = Duration.zero;
|
||||
Duration _duration = Duration.zero;
|
||||
bool _playing = false;
|
||||
bool _buffering = false;
|
||||
bool _isDragging = false;
|
||||
double _dragValue = 0.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_rotationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 14),
|
||||
);
|
||||
_position = widget.player.state.position;
|
||||
_duration = widget.player.state.duration;
|
||||
_playing = widget.player.state.playing;
|
||||
_buffering = widget.player.state.buffering;
|
||||
if (_playing) _rotationController.repeat();
|
||||
|
||||
_posSub = widget.player.stream.position.listen((pos) {
|
||||
if (mounted && !_isDragging) {
|
||||
setState(() => _position = pos);
|
||||
}
|
||||
});
|
||||
_durSub = widget.player.stream.duration.listen((dur) {
|
||||
if (mounted) setState(() => _duration = dur);
|
||||
});
|
||||
_playSub = widget.player.stream.playing.listen((playing) {
|
||||
if (mounted) {
|
||||
setState(() => _playing = playing);
|
||||
if (playing) {
|
||||
_rotationController.repeat();
|
||||
} else {
|
||||
_rotationController.stop();
|
||||
}
|
||||
}
|
||||
});
|
||||
_buffSub = widget.player.stream.buffering.listen((buffering) {
|
||||
if (mounted) setState(() => _buffering = buffering);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_rotationController.dispose();
|
||||
_posSub?.cancel();
|
||||
_durSub?.cancel();
|
||||
_playSub?.cancel();
|
||||
_buffSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _formatTime(Duration d) {
|
||||
final minutes = d.inMinutes;
|
||||
final seconds = d.inSeconds % 60;
|
||||
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String _deriveTitle() {
|
||||
final title = (widget.post.title ?? '').trim();
|
||||
if (title.isNotEmpty) return title;
|
||||
final uri = Uri.tryParse(widget.post.fileUrl);
|
||||
if (uri != null) {
|
||||
final q = uri.queryParameters['f'];
|
||||
if (q != null && q.trim().isNotEmpty) return q.trim();
|
||||
final seg = uri.pathSegments.lastOrNull;
|
||||
if (seg != null && seg.isNotEmpty) return seg;
|
||||
}
|
||||
return 'Аудиозапись';
|
||||
}
|
||||
|
||||
void _seekRelative(int seconds) {
|
||||
final target = _position + Duration(seconds: seconds);
|
||||
final maxDur = _duration > Duration.zero ? _duration : target;
|
||||
final clamped = Duration(
|
||||
milliseconds: target.inMilliseconds.clamp(0, maxDur.inMilliseconds),
|
||||
);
|
||||
widget.player.seek(clamped);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final title = _deriveTitle();
|
||||
final artistName =
|
||||
widget.post.tagGroups['artist']?.firstOrNull ?? widget.post.providerName;
|
||||
final hasCover = widget.post.previewUrl.isNotEmpty &&
|
||||
widget.post.previewUrl.startsWith('http');
|
||||
|
||||
if (widget.errorMessage != null) {
|
||||
return Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: theme.colorScheme.error.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline_rounded,
|
||||
size: 40, color: theme.colorScheme.error),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Не удалось воспроизвести аудио',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: widget.onRetry,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: const Text('Повторить попытку'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final maxMs = _duration.inMilliseconds > 0
|
||||
? _duration.inMilliseconds.toDouble()
|
||||
: 1.0;
|
||||
final currentMs = (_isDragging ? _dragValue : _position.inMilliseconds.toDouble())
|
||||
.clamp(0.0, maxMs);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
theme.colorScheme.surfaceContainer,
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.35),
|
||||
width: 0.8,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.25),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Top Row: Vinyl / Album Art + Title + Artist
|
||||
Row(
|
||||
children: [
|
||||
// Rotating Album Art / Vinyl
|
||||
RotationTransition(
|
||||
turns: _rotationController,
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
theme.colorScheme.primary.withValues(alpha: 0.9),
|
||||
Colors.black87,
|
||||
],
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.35),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: hasCover
|
||||
? ClipOval(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: widget.post.previewUrl,
|
||||
width: 44,
|
||||
height: 44,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
Icons.audiotrack_rounded,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
size: 26,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: theme.colorScheme.surface,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.audiotrack_rounded,
|
||||
size: 16,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Track & Artist Info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -0.2,
|
||||
height: 1.25,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.person_outline_rounded,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// Timeline Slider
|
||||
SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 4.5,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6.5),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
|
||||
activeTrackColor: theme.colorScheme.primary,
|
||||
inactiveTrackColor:
|
||||
theme.colorScheme.onSurface.withValues(alpha: 0.15),
|
||||
thumbColor: theme.colorScheme.primary,
|
||||
),
|
||||
child: Slider(
|
||||
value: currentMs,
|
||||
min: 0.0,
|
||||
max: maxMs,
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_isDragging = true;
|
||||
_dragValue = val;
|
||||
});
|
||||
},
|
||||
onChangeEnd: (val) {
|
||||
_isDragging = false;
|
||||
widget.player.seek(Duration(milliseconds: val.toInt()));
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Time numbers: elapsed & total
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(_position),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
if (_buffering)
|
||||
SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatTime(_duration),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Controls Row: Loop, -10s, Play/Pause, +10s, Mute
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: widget.loopAudio ? 'Повтор: включен' : 'Повтор: выключен',
|
||||
icon: Icon(
|
||||
widget.loopAudio ? Icons.repeat_one_rounded : Icons.repeat_rounded,
|
||||
color: widget.loopAudio
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
size: 22,
|
||||
),
|
||||
onPressed: widget.onToggleLoop,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Назад на 10 сек',
|
||||
icon: const Icon(Icons.replay_10_rounded, size: 26),
|
||||
color: theme.colorScheme.onSurface,
|
||||
onPressed: () => _seekRelative(-10),
|
||||
),
|
||||
// Big Play / Pause Button
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (_playing) {
|
||||
widget.player.pause();
|
||||
} else {
|
||||
widget.player.play();
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
width: 54,
|
||||
height: 54,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.4),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
_playing ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
size: 30,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Вперед на 10 сек',
|
||||
icon: const Icon(Icons.forward_10_rounded, size: 26),
|
||||
color: theme.colorScheme.onSurface,
|
||||
onPressed: () => _seekRelative(10),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: widget.muted ? 'Включить звук' : 'Выключить звук',
|
||||
icon: Icon(
|
||||
widget.muted ? Icons.volume_off_rounded : Icons.volume_up_rounded,
|
||||
color: widget.muted
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
size: 22,
|
||||
),
|
||||
onPressed: widget.onToggleMute,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoSurface extends StatefulWidget {
|
||||
const _VideoSurface({
|
||||
required this.player,
|
||||
|
||||
@@ -117,14 +117,19 @@ class _PostCardState extends ConsumerState<PostCard>
|
||||
Widget build(BuildContext context) {
|
||||
final post = _resolvedPost ?? widget.post;
|
||||
final sensitive = _isSensitive(post.rating);
|
||||
final isAudioPost =
|
||||
MediaUrlSelector.isAudio(post) || post.fileType == 'audio';
|
||||
final isVideoPost = MediaUrlSelector.isVideo(post);
|
||||
final isTextPost = post.fileType == 'text' ||
|
||||
(post.previewUrl.trim().isEmpty &&
|
||||
post.sampleUrl.trim().isEmpty &&
|
||||
post.fileUrl.trim().isEmpty &&
|
||||
!MediaUrlSelector.isVideo(post));
|
||||
!isVideoPost &&
|
||||
!isAudioPost);
|
||||
final aspect = post.width > 0 && post.height > 0
|
||||
? post.width / post.height
|
||||
: _fallbackAspect(isTextPost ? 'text' : post.fileType);
|
||||
: _fallbackAspect(
|
||||
isTextPost ? 'text' : (isAudioPost ? 'audio' : post.fileType));
|
||||
final mobile = MediaQuery.sizeOf(context).width < 700;
|
||||
final urls = _feedUrls(post, mobile: mobile);
|
||||
final imageUrl = urls.isEmpty ? post.previewUrl : urls.first;
|
||||
@@ -213,6 +218,18 @@ class _PostCardState extends ConsumerState<PostCard>
|
||||
hovered: _hovered && !mobile,
|
||||
);
|
||||
}
|
||||
if (isAudioPost && (imageUrl.isEmpty || !imageUrl.startsWith('http'))) {
|
||||
return _AudioPostCardPreview(
|
||||
post: post,
|
||||
hovered: _hovered && !mobile,
|
||||
);
|
||||
}
|
||||
if (isVideoPost && (imageUrl.isEmpty || !imageUrl.startsWith('http'))) {
|
||||
return _VideoPostCardPreview(
|
||||
post: post,
|
||||
hovered: _hovered && !mobile,
|
||||
);
|
||||
}
|
||||
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||
final cacheWidth =
|
||||
(constraints.maxWidth * dpr).round().clamp(320, 1800);
|
||||
@@ -241,8 +258,14 @@ class _PostCardState extends ConsumerState<PostCard>
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
child: const Center(
|
||||
child: Icon(Icons.broken_image_rounded),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
isVideoPost
|
||||
? Icons.videocam_outlined
|
||||
: (isAudioPost
|
||||
? Icons.music_note_rounded
|
||||
: Icons.broken_image_rounded),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -282,12 +305,15 @@ class _PostCardState extends ConsumerState<PostCard>
|
||||
post.fileType.toLowerCase().contains('webm') ||
|
||||
post.fileType.toLowerCase().contains('mp4') ||
|
||||
post.fileType.toLowerCase().contains('gif') ||
|
||||
isAudioPost ||
|
||||
isTextPost)
|
||||
Positioned(
|
||||
right: 8,
|
||||
top: 8,
|
||||
child: _MediaBadge(
|
||||
fileType: isTextPost ? 'text' : post.fileType,
|
||||
fileType: isAudioPost
|
||||
? 'audio'
|
||||
: (isTextPost ? 'text' : post.fileType),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
@@ -450,6 +476,7 @@ class _PostCardState extends ConsumerState<PostCard>
|
||||
double _fallbackAspect(String fileType) {
|
||||
final normalized = fileType.toLowerCase();
|
||||
if (normalized == 'text') return 0.95;
|
||||
if (normalized == 'audio' || normalized.contains('mp3')) return 1.25;
|
||||
if (normalized.contains('video') || normalized.contains('webm')) {
|
||||
return 16 / 9;
|
||||
}
|
||||
@@ -806,6 +833,14 @@ class _MediaBadge extends StatelessWidget {
|
||||
|
||||
String _type(String raw) {
|
||||
final value = raw.toLowerCase();
|
||||
if (value.contains('audio') ||
|
||||
value.contains('mp3') ||
|
||||
value.contains('wav') ||
|
||||
value.contains('flac') ||
|
||||
value.contains('ogg') ||
|
||||
value.contains('m4a')) {
|
||||
return 'audio';
|
||||
}
|
||||
if (value.contains('video') ||
|
||||
value.contains('webm') ||
|
||||
value.contains('mp4')) {
|
||||
@@ -817,6 +852,7 @@ class _MediaBadge extends StatelessWidget {
|
||||
}
|
||||
|
||||
IconData _icon(String type) => switch (type) {
|
||||
'audio' => Icons.music_note_rounded,
|
||||
'video' => Icons.play_arrow_rounded,
|
||||
'gif' => Icons.gif_box_rounded,
|
||||
'text' => Icons.article_rounded,
|
||||
@@ -824,6 +860,7 @@ class _MediaBadge extends StatelessWidget {
|
||||
};
|
||||
|
||||
String _label(String type) => switch (type) {
|
||||
'audio' => 'AUDIO',
|
||||
'video' => 'VIDEO',
|
||||
'gif' => 'GIF',
|
||||
'text' => 'ТЕКСТ',
|
||||
@@ -967,6 +1004,251 @@ class _TextPostCardPreview extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _AudioPostCardPreview extends StatelessWidget {
|
||||
const _AudioPostCardPreview({
|
||||
required this.post,
|
||||
required this.hovered,
|
||||
});
|
||||
|
||||
final Post post;
|
||||
final bool hovered;
|
||||
|
||||
String _deriveAudioTitle() {
|
||||
final title = (post.title ?? '').trim();
|
||||
if (title.isNotEmpty) return title;
|
||||
final uri = Uri.tryParse(post.fileUrl);
|
||||
if (uri != null) {
|
||||
final queryParam = uri.queryParameters['f'];
|
||||
if (queryParam != null && queryParam.trim().isNotEmpty) {
|
||||
return queryParam.trim();
|
||||
}
|
||||
final lastSeg = uri.pathSegments.lastOrNull;
|
||||
if (lastSeg != null && lastSeg.isNotEmpty) {
|
||||
return lastSeg;
|
||||
}
|
||||
}
|
||||
final artist = post.tagGroups['artist']?.firstOrNull;
|
||||
if (artist != null && artist.isNotEmpty) return 'Аудиозапись от $artist';
|
||||
return 'Аудиозапись';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final audioTitle = _deriveAudioTitle();
|
||||
final artistName = post.tagGroups['artist']?.firstOrNull ?? post.providerName;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
theme.colorScheme.primaryContainer.withValues(alpha: 0.85),
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
],
|
||||
),
|
||||
border: Border.all(
|
||||
color: hovered
|
||||
? theme.colorScheme.primary.withValues(alpha: 0.6)
|
||||
: theme.colorScheme.outlineVariant.withValues(alpha: 0.35),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
Icons.audiotrack_rounded,
|
||||
size: 20,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${post.createdAt.day.toString().padLeft(2, '0')}.${post.createdAt.month.toString().padLeft(2, '0')}.${post.createdAt.year}',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
audioTitle,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.25,
|
||||
letterSpacing: -0.1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.play_circle_filled_rounded,
|
||||
size: 14,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Воспроизвести',
|
||||
style: TextStyle(
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoPostCardPreview extends StatelessWidget {
|
||||
const _VideoPostCardPreview({
|
||||
required this.post,
|
||||
required this.hovered,
|
||||
});
|
||||
|
||||
final Post post;
|
||||
final bool hovered;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final title = (post.title ?? '').trim();
|
||||
final artistName = post.tagGroups['artist']?.firstOrNull ?? post.providerName;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
theme.colorScheme.surfaceContainer,
|
||||
],
|
||||
),
|
||||
border: Border.all(
|
||||
color: hovered
|
||||
? theme.colorScheme.primary.withValues(alpha: 0.6)
|
||||
: theme.colorScheme.outlineVariant.withValues(alpha: 0.35),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.85),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
size: 32,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (title.isNotEmpty)
|
||||
Text(
|
||||
title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.2,
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SeenBadge extends StatelessWidget {
|
||||
const _SeenBadge();
|
||||
|
||||
|
||||
+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.5.2+78
|
||||
version: 3.5.3+79
|
||||
|
||||
environment:
|
||||
sdk: ">=3.3.0 <4.0.0"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gel_rule_app/backend/models/artist_work_query.dart';
|
||||
import 'package:gel_rule_app/backend/models/content_provider_config.dart';
|
||||
import 'package:gel_rule_app/backend/models/post.dart';
|
||||
import 'package:gel_rule_app/backend/providers/pawchive_provider.dart';
|
||||
import 'package:gel_rule_app/backend/providers/provider_factory.dart';
|
||||
import 'package:gel_rule_app/backend/utils/media_quality.dart';
|
||||
import 'package:gel_rule_app/core/http/dio_client.dart';
|
||||
|
||||
void main() {
|
||||
@@ -47,4 +49,67 @@ void main() {
|
||||
expect(provider.id, query.providerId);
|
||||
expect(query.artistName, 'BOKABA');
|
||||
});
|
||||
|
||||
test('MediaUrlSelector accurately classifies audio, video and photo posts', () {
|
||||
final audioPost = Post(
|
||||
id: 'patreon:123:456:0',
|
||||
providerId: 'pawchive',
|
||||
providerName: 'Pawchive',
|
||||
previewUrl: '',
|
||||
sampleUrl: '',
|
||||
fileUrl:
|
||||
'https://file.pawchive.pw/data/ae/13/ae13adaaebee798397c5c3b07cb9690b2adc91f41815e89a3f58a447bfb56980.mp3?f=audio%20spicy%20only%204.MP3',
|
||||
tags: const ['patreon', 'artist', 'audio'],
|
||||
rating: 'unknown',
|
||||
width: 0,
|
||||
height: 0,
|
||||
createdAt: DateTime.now(),
|
||||
fileType: 'audio',
|
||||
score: 0,
|
||||
);
|
||||
|
||||
final videoPost = Post(
|
||||
id: 'patreon:123:456:1',
|
||||
providerId: 'pawchive',
|
||||
providerName: 'Pawchive',
|
||||
previewUrl: '',
|
||||
sampleUrl: '',
|
||||
fileUrl:
|
||||
'https://file.pawchive.pw/data/84/b1/84b1d076dd6b59047bb699115e57c18a02f2ea08b54e4d20409692b58b7aac33.mp4',
|
||||
tags: const ['patreon', 'artist'],
|
||||
rating: 'unknown',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
createdAt: DateTime.now(),
|
||||
fileType: 'video',
|
||||
score: 0,
|
||||
);
|
||||
|
||||
final photoPost = Post(
|
||||
id: 'patreon:123:456:2',
|
||||
providerId: 'pawchive',
|
||||
providerName: 'Pawchive',
|
||||
previewUrl: 'https://img.pawchive.pw/thumbnail/data/c3/06/c3060ab6fa.png',
|
||||
sampleUrl: 'https://img.pawchive.pw/thumbnail/data/c3/06/c3060ab6fa.png',
|
||||
fileUrl: 'https://file.pawchive.pw/data/c3/06/c3060ab6fa.png',
|
||||
tags: const ['patreon', 'artist'],
|
||||
rating: 'unknown',
|
||||
width: 1200,
|
||||
height: 1600,
|
||||
createdAt: DateTime.now(),
|
||||
fileType: 'photo',
|
||||
score: 0,
|
||||
);
|
||||
|
||||
expect(MediaUrlSelector.isAudio(audioPost), isTrue);
|
||||
expect(MediaUrlSelector.isVideo(audioPost), isFalse);
|
||||
expect(MediaUrlSelector.audio(audioPost), contains(audioPost.fileUrl));
|
||||
|
||||
expect(MediaUrlSelector.isVideo(videoPost), isTrue);
|
||||
expect(MediaUrlSelector.isAudio(videoPost), isFalse);
|
||||
|
||||
expect(MediaUrlSelector.isAudio(photoPost), isFalse);
|
||||
expect(MediaUrlSelector.isVideo(photoPost), isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user