From 5e3efb0da9a69bbe533b90575ab53b353a13433e Mon Sep 17 00:00:00 2001 From: RarDog Date: Sat, 5 Sep 2026 15:16:08 +0300 Subject: [PATCH] feat: add audio playback support (MP3/WAV/FLAC) and fix attachment loading in creator posts --- README.md | 2 +- README_RU.md | 2 +- lib/app/app_version.dart | 4 +- lib/app/changelog.dart | 10 + lib/backend/providers/kemono_provider.dart | 43 +- lib/backend/providers/pawchive_provider.dart | 76 ++- lib/backend/utils/media_quality.dart | 51 +- .../presentation/artist_posts_screen.dart | 39 +- .../presentation/post_details_screen.dart | 40 +- .../widgets/post_media_viewer.dart | 501 +++++++++++++++++- lib/shared/widgets/post_card.dart | 292 +++++++++- pubspec.yaml | 2 +- test/backend/pawchive_provider_test.dart | 65 +++ 13 files changed, 1073 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 9ac8693..16b3ba2 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

- Version 3.5.2 + Version 3.5.3 Flutter Platforms Wiki diff --git a/README_RU.md b/README_RU.md index 27c50d9..7de87ae 100644 --- a/README_RU.md +++ b/README_RU.md @@ -14,7 +14,7 @@

- Version 3.5.2 + Version 3.5.3 Flutter Platforms Wiki diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart index 3069ec3..09c7d43 100644 --- a/lib/app/app_version.dart +++ b/lib/app/app_version.dart @@ -1,3 +1,3 @@ -const appDisplayVersion = '3.5.2'; -const appBuildNumber = 78; +const appDisplayVersion = '3.5.3'; +const appBuildNumber = 79; const appReleaseChannel = 'Stable'; diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart index 92a0a77..cc69d6c 100644 --- a/lib/app/changelog.dart +++ b/lib/app/changelog.dart @@ -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: 'Исправление наложения клавиатуры и поиска авторов', diff --git a/lib/backend/providers/kemono_provider.dart b/lib/backend/providers/kemono_provider.dart index 796f4c4..5160d9d 100644 --- a/lib/backend/providers/kemono_provider.dart +++ b/lib/backend/providers/kemono_provider.dart @@ -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'; diff --git a/lib/backend/providers/pawchive_provider.dart b/lib/backend/providers/pawchive_provider.dart index 0a1555b..0350058 100644 --- a/lib/backend/providers/pawchive_provider.dart +++ b/lib/backend/providers/pawchive_provider.dart @@ -414,7 +414,15 @@ class PawchiveProvider } final attachments = json['attachments']; if (attachments is List) { - files.addAll(attachments.whereType().map(Map.from)); + for (final att in attachments.whereType()) { + final m = Map.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) { diff --git a/lib/backend/utils/media_quality.dart b/lib/backend/utils/media_quality.dart index d812d03..e8dc801 100644 --- a/lib/backend/utils/media_quality.dart +++ b/lib/backend/utils/media_quality.dart @@ -80,7 +80,16 @@ class MediaUrlSelector { ]); } + static List 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 _compact(Iterable 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'); } } diff --git a/lib/features/artists/presentation/artist_posts_screen.dart b/lib/features/artists/presentation/artist_posts_screen.dart index 07ae0e0..5cc251f 100644 --- a/lib/features/artists/presentation/artist_posts_screen.dart +++ b/lib/features/artists/presentation/artist_posts_screen.dart @@ -191,11 +191,13 @@ class _ArtistPostsScreenState extends ConsumerState { 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 { 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 { 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 { 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 { }); }, ), + 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(() { diff --git a/lib/features/post/presentation/post_details_screen.dart b/lib/features/post/presentation/post_details_screen.dart index ee26919..dee46ce 100644 --- a/lib/features/post/presentation/post_details_screen.dart +++ b/lib/features/post/presentation/post_details_screen.dart @@ -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? 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, diff --git a/lib/features/post/presentation/widgets/post_media_viewer.dart b/lib/features/post/presentation/widgets/post_media_viewer.dart index d0a1d77..006ec45 100644 --- a/lib/features/post/presentation/widgets/post_media_viewer.dart +++ b/lib/features/post/presentation/widgets/post_media_viewer.dart @@ -94,7 +94,7 @@ class _PostMediaViewerState extends State _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 _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 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 } List _buildVideoUrls(Post post) { - final list = List.from(MediaUrlSelector.video(post)); + final list = _isAudio(post) + ? List.from(MediaUrlSelector.audio(post)) + : List.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 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? 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? _posSub; + StreamSubscription? _durSub; + StreamSubscription? _playSub; + StreamSubscription? _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, diff --git a/lib/shared/widgets/post_card.dart b/lib/shared/widgets/post_card.dart index 016ebb2..1b16f08 100644 --- a/lib/shared/widgets/post_card.dart +++ b/lib/shared/widgets/post_card.dart @@ -117,14 +117,19 @@ class _PostCardState extends ConsumerState 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 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 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 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 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(); diff --git a/pubspec.yaml b/pubspec.yaml index edacd77..57a1144 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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" diff --git a/test/backend/pawchive_provider_test.dart b/test/backend/pawchive_provider_test.dart index b789b5f..b3691de 100644 --- a/test/backend/pawchive_provider_test.dart +++ b/test/backend/pawchive_provider_test.dart @@ -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); + }); } +