diff --git a/README.md b/README.md
index 33f8eff..d1cbee5 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
diff --git a/lib/app/app_strings.dart b/lib/app/app_strings.dart
index 186fa46..1072c70 100644
--- a/lib/app/app_strings.dart
+++ b/lib/app/app_strings.dart
@@ -36,6 +36,7 @@ class AppStrings {
String get downloading => ru ? 'Скачивается' : 'Downloading';
String get failed => ru ? 'Ошибка' : 'Failed';
String get open => ru ? 'Открыть' : 'Open';
+ String get copyLink => ru ? 'Копировать ссылку' : 'Copy link';
String get hidePost => ru ? 'Скрыть пост' : 'Hide post';
String get hiddenLocally =>
ru ? 'Пост скрыт локально' : 'Post hidden locally';
diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart
index baa7dcb..5ba95c1 100644
--- a/lib/app/app_version.dart
+++ b/lib/app/app_version.dart
@@ -1,3 +1,3 @@
-const appDisplayVersion = '2.2.0';
-const appBuildNumber = 39;
+const appDisplayVersion = '2.3.0';
+const appBuildNumber = 40;
const appReleaseChannel = 'Stable';
diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart
index a6aef3e..99f762d 100644
--- a/lib/app/changelog.dart
+++ b/lib/app/changelog.dart
@@ -1,4 +1,16 @@
const lunarisChangelog = [
+ LunarisChange(
+ version: '2.3.0',
+ title: 'Artist Cloud Drives & External Video Mirrors',
+ bullets: [
+ 'Cloud Mirrors & Drives: automatic detection and parsing of external links to Google Drive, MEGA, Dropbox, Pixeldrain, Catbox, MediaFire, GoFile, Bunkr, and direct video files from post descriptions, HTML, and embed data.',
+ 'Direct Video Playback & Download: resolves direct streaming URLs for Dropbox (?raw=1), Google Drive, Pixeldrain API, and Catbox with 1-tap playback and direct downloads.',
+ 'Cloud Mirrors Card: styled cards with service branding colors (MEGA Red, Google Drive Blue, Dropbox Blue, Pixeldrain Cyan) showing link type, direct stream button, external open, and copy link.',
+ 'Link-Only Posts Support: artist posts that only contain cloud drive links without direct server files are now displayed with an elegant CloudMediaHero banner instead of an empty view.',
+ 'Archive Password Detection: automatically extracts and displays archive passwords (Pass: ..., Password: ...) with 1-tap copy button.',
+ 'Artist Commentary Card: formatted display of author notes, instructions, and text below post details.',
+ ],
+ ),
LunarisChange(
version: '2.2.0',
title: 'Offline Mode Overhaul & Post Details Screen Redesign',
diff --git a/lib/backend/backend.dart b/lib/backend/backend.dart
index 35b2821..af9f3e2 100644
--- a/lib/backend/backend.dart
+++ b/lib/backend/backend.dart
@@ -7,6 +7,7 @@ export 'models/artist_work_query.dart';
export 'models/artist_tag.dart';
export 'models/artist_link.dart';
export 'models/artist_announcement.dart';
+export 'models/cloud_media_link.dart';
export 'models/download_task.dart';
export 'models/downloaded_media.dart';
export 'models/favorite.dart';
diff --git a/lib/backend/models/cloud_media_link.dart b/lib/backend/models/cloud_media_link.dart
new file mode 100644
index 0000000..3b33dda
--- /dev/null
+++ b/lib/backend/models/cloud_media_link.dart
@@ -0,0 +1,155 @@
+import 'dart:convert';
+import 'package:flutter/material.dart';
+
+enum CloudServiceType {
+ googleDrive,
+ mega,
+ dropbox,
+ pixeldrain,
+ catbox,
+ mediafire,
+ bunkr,
+ gofile,
+ terabox,
+ genericVideo,
+ other,
+}
+
+class CloudMediaLink {
+ const CloudMediaLink({
+ required this.url,
+ required this.service,
+ required this.title,
+ this.directStreamUrl,
+ this.isFolder = false,
+ this.isStreamable = false,
+ this.detectedPassword,
+ });
+
+ final String url;
+ final CloudServiceType service;
+ final String title;
+ final String? directStreamUrl;
+ final bool isFolder;
+ final bool isStreamable;
+ final String? detectedPassword;
+
+ String get serviceName {
+ switch (service) {
+ case CloudServiceType.googleDrive:
+ return 'Google Drive';
+ case CloudServiceType.mega:
+ return 'MEGA';
+ case CloudServiceType.dropbox:
+ return 'Dropbox';
+ case CloudServiceType.pixeldrain:
+ return 'Pixeldrain';
+ case CloudServiceType.catbox:
+ return 'Catbox';
+ case CloudServiceType.mediafire:
+ return 'MediaFire';
+ case CloudServiceType.bunkr:
+ return 'Bunkr';
+ case CloudServiceType.gofile:
+ return 'GoFile';
+ case CloudServiceType.terabox:
+ return 'TeraBox';
+ case CloudServiceType.genericVideo:
+ return 'Direct Video';
+ case CloudServiceType.other:
+ return 'External Mirror';
+ }
+ }
+
+ Color get brandColor {
+ switch (service) {
+ case CloudServiceType.googleDrive:
+ return const Color(0xFF4285F4); // Google Blue
+ case CloudServiceType.mega:
+ return const Color(0xFFD9272E); // Mega Red
+ case CloudServiceType.dropbox:
+ return const Color(0xFF0061FF); // Dropbox Blue
+ case CloudServiceType.pixeldrain:
+ return const Color(0xFF00B4D8); // Cyan
+ case CloudServiceType.catbox:
+ return const Color(0xFFFF6F00); // Amber/Orange
+ case CloudServiceType.mediafire:
+ return const Color(0xFF1E88E5); // MediaFire Blue
+ case CloudServiceType.bunkr:
+ return const Color(0xFF9C27B0); // Purple
+ case CloudServiceType.gofile:
+ return const Color(0xFF26A69A); // Teal
+ case CloudServiceType.terabox:
+ return const Color(0xFF29B6F6); // Light Blue
+ case CloudServiceType.genericVideo:
+ return const Color(0xFFE91E63); // Pink
+ case CloudServiceType.other:
+ return const Color(0xFF78909C); // Slate
+ }
+ }
+
+ IconData get iconData {
+ if (isFolder) return Icons.folder_zip_rounded;
+ if (isStreamable || service == CloudServiceType.genericVideo) {
+ return Icons.play_circle_filled_rounded;
+ }
+ switch (service) {
+ case CloudServiceType.googleDrive:
+ return Icons.add_to_drive_rounded;
+ case CloudServiceType.mega:
+ return Icons.cloud_circle_rounded;
+ case CloudServiceType.dropbox:
+ return Icons.cloud_done_rounded;
+ case CloudServiceType.pixeldrain:
+ case CloudServiceType.catbox:
+ case CloudServiceType.mediafire:
+ case CloudServiceType.bunkr:
+ case CloudServiceType.gofile:
+ case CloudServiceType.terabox:
+ return Icons.cloud_download_rounded;
+ case CloudServiceType.genericVideo:
+ return Icons.videocam_rounded;
+ case CloudServiceType.other:
+ return Icons.link_rounded;
+ }
+ }
+
+ Map toJson() => {
+ 'url': url,
+ 'service': service.name,
+ 'title': title,
+ if (directStreamUrl != null) 'directStreamUrl': directStreamUrl,
+ 'isFolder': isFolder,
+ 'isStreamable': isStreamable,
+ if (detectedPassword != null) 'detectedPassword': detectedPassword,
+ };
+
+ factory CloudMediaLink.fromJson(Map json) {
+ final serviceStr = (json['service'] ?? '').toString();
+ final service = CloudServiceType.values.firstWhere(
+ (e) => e.name == serviceStr,
+ orElse: () => CloudServiceType.other,
+ );
+ return CloudMediaLink(
+ url: (json['url'] ?? '').toString(),
+ service: service,
+ title: (json['title'] ?? '').toString(),
+ directStreamUrl: json['directStreamUrl']?.toString(),
+ isFolder: json['isFolder'] == true,
+ isStreamable: json['isStreamable'] == true,
+ detectedPassword: json['detectedPassword']?.toString(),
+ );
+ }
+
+ String encode() => jsonEncode(toJson());
+
+ static CloudMediaLink? tryDecode(String str) {
+ try {
+ final decoded = jsonDecode(str);
+ if (decoded is Map) {
+ return CloudMediaLink.fromJson(decoded);
+ }
+ } catch (_) {}
+ return null;
+ }
+}
diff --git a/lib/backend/models/post.dart b/lib/backend/models/post.dart
index 10e63dd..b175386 100644
--- a/lib/backend/models/post.dart
+++ b/lib/backend/models/post.dart
@@ -1,3 +1,6 @@
+import 'cloud_media_link.dart';
+import '../services/cloud_link_extractor.dart';
+
class Post {
const Post({
required this.id,
@@ -35,6 +38,28 @@ class Post {
String get cacheKey => '$providerId:$id';
+ List get cloudLinks {
+ final list = tagGroups['cloud_links'];
+ if (list != null && list.isNotEmpty) {
+ return list
+ .map((e) => CloudMediaLink.tryDecode(e))
+ .whereType()
+ .toList(growable: false);
+ }
+ if (source != null && source!.isNotEmpty) {
+ return CloudLinkExtractor.extractLinks(source: source);
+ }
+ return const [];
+ }
+
+ String? get commentary {
+ final list = tagGroups['description'];
+ if (list != null && list.isNotEmpty) {
+ return list.first;
+ }
+ return null;
+ }
+
Post copyWith({
String? id,
String? providerId,
diff --git a/lib/backend/providers/kemono_provider.dart b/lib/backend/providers/kemono_provider.dart
index b617614..e6143c9 100644
--- a/lib/backend/providers/kemono_provider.dart
+++ b/lib/backend/providers/kemono_provider.dart
@@ -10,6 +10,7 @@ import '../models/post.dart';
import '../models/post_comment.dart';
import '../models/provider_health.dart';
import '../models/top_period_filter.dart';
+import '../services/cloud_link_extractor.dart';
import 'content_provider.dart';
class KemonoProvider
@@ -361,6 +362,18 @@ class KemonoProvider
_dateFromAny(json['added']) ??
_dateFromAny(json['edited']) ??
DateTime.fromMillisecondsSinceEpoch(0);
+ final postSource =
+ '$baseUrl/${query.service}/user/${query.artistId}/post/$postId';
+ final rawContent = (json['content'] ?? '').toString();
+ final embed = json['embed'] is Map ? Map.from(json['embed']) : null;
+ final cloudLinks = CloudLinkExtractor.extractLinks(
+ content: rawContent,
+ source: postSource,
+ embed: embed,
+ );
+ final cloudLinksJson = cloudLinks.map((e) => e.encode()).toList(growable: false);
+ final cleanDesc = CloudLinkExtractor.cleanCommentary(rawContent);
+
final files =