Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
import 'package:flutter/foundation.dart';
abstract final class AppLogger {
static void debug(String tag, String message, [Object? error]) {
if (!kDebugMode) return;
_emit('D', tag, message, error);
}
static void info(String tag, String message) {
if (!kDebugMode) return;
_emit('I', tag, message);
}
static void warn(String tag, String message, [Object? error]) {
if (!kDebugMode) return;
_emit('W', tag, message, error);
}
static void error(
String tag,
String message,
Object error, [
StackTrace? stack,
]) {
_emit('E', tag, message, error);
if (stack != null && kDebugMode) {
debugPrint(
'[$tag] stack: ${stack.toString().split('\n').take(8).join('\n')}',
);
}
}
static void _emit(String level, String tag, String message, [Object? error]) {
final suffix = error != null ? ' err=$error' : '';
debugPrint('[$level/$tag] $message$suffix');
}
}
+20
View File
@@ -0,0 +1,20 @@
import 'dart:async';
class AsyncLock {
Future<void>? _lastOp;
Future<T> run<T>(Future<T> Function() action) async {
final previous = _lastOp;
final completer = Completer<void>();
_lastOp = completer.future;
if (previous != null) {
await previous;
}
try {
return await action();
} finally {
completer.complete();
}
}
}
+10
View File
@@ -0,0 +1,10 @@
import 'package:flutter/painting.dart';
Color accentColorForSeed(String seed, {required bool isDark}) {
final hash = seed.runes.fold<int>(0, (value, rune) => value * 37 + rune);
final hue = (hash % 360).toDouble();
final saturation = isDark ? 0.48 : 0.40;
final lightness = isDark ? 0.42 : 0.70;
return HSLColor.fromAHSL(1, hue, saturation, lightness).toColor();
}
@@ -0,0 +1,39 @@
import '../../core/contracts/library.dart';
CrossServerSearchReq? buildEpisodeCrossServerReq({
required EmbyRawItem episode,
EmbyRawItem? seriesItem,
int? seasonNumberOverride,
}) {
final seasonNumber =
seasonNumberOverride ??
(episode.extra['ParentIndexNumber'] as num?)?.toInt();
final episodeNumber = episode.IndexNumber;
if (seasonNumber == null || episodeNumber == null) return null;
final providerIds = providerIdsOfItem(episode);
final seriesProviderIds = seriesItem != null
? providerIdsOfItem(seriesItem)
: const <String, String>{};
if (providerIds.isEmpty && seriesProviderIds.isEmpty) return null;
return CrossServerSearchReq(
anchorType: 'Episode',
itemName: episode.Name,
providerIds: providerIds,
seriesProviderIds: seriesProviderIds,
parentIndexNumber: seasonNumber,
indexNumber: episodeNumber,
);
}
String encodeProviderIdsForKey(Map<String, String> ids) {
if (ids.isEmpty) return '';
final entries = ids.entries.toList()..sort((a, b) => a.key.compareTo(b.key));
return entries
.where((e) => e.value.isNotEmpty)
.map((e) => '${e.key}.${e.value}')
.join(',');
}
+28
View File
@@ -0,0 +1,28 @@
import '../../core/contracts/library.dart';
const int kTicksPerSecond = 10000000;
const int kTicksPerMinute = 600000000;
int ticksFromDuration(Duration duration) => duration.inMicroseconds * 10;
Duration durationFromTicks(int ticks) => Duration(microseconds: ticks ~/ 10);
double playbackProgress(EmbyRawItem item) {
final position = item.UserData?.PlaybackPositionTicks;
final total = item.RunTimeTicks;
if (position == null || total == null || total <= 0) return 0;
final value = position / total;
if (value.isNaN || value.isInfinite) return 0;
return value.clamp(0.0, 1.0);
}
String? formatRemainingLabel(EmbyRawItem item) {
final position = item.UserData?.PlaybackPositionTicks;
final total = item.RunTimeTicks;
if (position == null || total == null || total <= 0) return null;
final minutes = ((total - position) / kTicksPerMinute).ceil();
return minutes > 0 ? '剩余 $minutes 分钟' : null;
}
+69
View File
@@ -0,0 +1,69 @@
import 'emby_ticks.dart';
String? formatRuntimeMinutes(int? minutes) {
if (minutes == null || minutes <= 0) return null;
final h = minutes ~/ 60;
final m = minutes % 60;
if (h > 0) return m > 0 ? '$h小时$m分钟' : '$h小时';
return '$m分钟';
}
String formatFileSize(int bytes) {
if (bytes >= 1073741824) {
return '${(bytes / 1073741824).toStringAsFixed(2)}G';
}
if (bytes >= 1048576) {
return '${(bytes / 1048576).toStringAsFixed(0)}M';
}
return '${(bytes / 1024).toStringAsFixed(0)}K';
}
String? formatEpisodeNumber(int? index) {
if (index == null) return null;
return '$index集';
}
String? formatSeasonNumber(int? season) {
if (season == null) return null;
return '$season季';
}
double? downloadProgressValue({required int received, required int total}) {
if (total <= 0) return null;
final value = received / total;
return value.clamp(0, 1).toDouble();
}
String formatDownloadBytes(int bytes) {
if (bytes <= 0) return '-- MB';
return '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB';
}
String formatDownloadSpeed(double bytesPerSec) {
if (bytesPerSec <= 0) return '0 B/s';
if (bytesPerSec >= 1024 * 1024) {
return '${(bytesPerSec / (1024 * 1024)).toStringAsFixed(1)} MB/s';
}
if (bytesPerSec >= 1024) {
return '${(bytesPerSec / 1024).toStringAsFixed(0)} KB/s';
}
return '${bytesPerSec.toStringAsFixed(0)} B/s';
}
String formatDurationClock(Duration duration) {
final h = duration.inHours;
final m = duration.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
return h > 0 ? '$h:$m:$s' : '$m:$s';
}
String formatTicksAsClock(int ticks) =>
formatDurationClock(ticks <= 0 ? Duration.zero : durationFromTicks(ticks));
+23
View File
@@ -0,0 +1,23 @@
void evictOldestEntries(Map<String, dynamic> map, int maxSize) {
if (map.length <= maxSize) return;
final entries = map.entries.toList()
..sort((a, b) {
final aTime =
DateTime.tryParse(
(a.value as Map<String, dynamic>)['updatedAt'] as String? ?? '',
) ??
DateTime(2000);
final bTime =
DateTime.tryParse(
(b.value as Map<String, dynamic>)['updatedAt'] as String? ?? '',
) ??
DateTime(2000);
return aTime.compareTo(bTime);
});
final removeCount = map.length - maxSize;
for (var i = 0; i < removeCount; i++) {
map.remove(entries[i].key);
}
}
+131
View File
@@ -0,0 +1,131 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/contracts/library.dart';
import '../../core/contracts/tmdb.dart';
import '../../core/infra/emby_api/emby_headers.dart';
import '../../features/player/player_launcher.dart';
import '../../providers/di_providers.dart';
import '../../providers/session_provider.dart';
import '../../providers/tmdb_prefetch_provider.dart';
import 'platform_device_name.dart';
const _kContainerTypes = <String>{
'BoxSet',
'CollectionFolder',
'Folder',
'Playlist',
'UserView',
};
void goMediaDetail(BuildContext context, EmbyRawItem item, {WidgetRef? ref}) {
if (_kContainerTypes.contains(item.Type)) {
context.push(
Uri(
path: '/library/${item.Id}',
queryParameters: {
'collection': '1',
if (item.Name.isNotEmpty) 'title': item.Name,
},
).toString(),
);
return;
}
if (ref != null) {
ref.read(tmdbPrefetchProvider.notifier).prefetchItem(item, priority: true);
}
final isEpisode = item.Type == 'Episode';
final seriesId = isEpisode && (item.SeriesId?.isNotEmpty ?? false)
? item.SeriesId
: null;
final seasonId = isEpisode && (item.SeasonId?.isNotEmpty ?? false)
? item.SeasonId
: null;
final query = <String, String>{
if (seriesId != null) 'seriesId': seriesId,
if (seasonId != null) 'seasonId': seasonId,
};
context.push(
Uri(
path: '/media/${item.Id}',
queryParameters: query.isNotEmpty ? query : null,
).toString(),
extra: item,
);
}
Future<PlayerExitResult?> playFromTmdbOnServer(
BuildContext context,
WidgetRef ref, {
required String serverId,
required String itemId,
String? mediaSourceId,
int preferredSubtitleStreamIndex = -1,
int? preferredAudioStreamIndex,
bool fromStart = false,
int? startPositionTicks,
String? backdropUrl,
}) async {
final active = ref.read(activeSessionProvider);
if (active?.serverId != serverId) {
await ref.read(sessionProvider.notifier).switchSession(serverId);
}
if (!context.mounted) return null;
return PlayerLauncher.launch(
context: context,
itemId: itemId,
serverId: serverId,
mediaSourceId: mediaSourceId,
preferredSubtitleStreamIndex: preferredSubtitleStreamIndex,
preferredAudioStreamIndex: preferredAudioStreamIndex,
startPositionTicks: startPositionTicks,
fromStart: fromStart,
backdropUrl: backdropUrl,
ref: ref,
);
}
void goTmdbDetail(
BuildContext context, {
required int tmdbId,
required String mediaType,
TmdbRecommendation? seed,
}) {
if (mediaType != 'movie' && mediaType != 'tv') return;
context.push('/tmdb/$mediaType/$tmdbId', extra: seed);
}
Future<void> goMediaDetailOnServer(
BuildContext context,
WidgetRef ref, {
required String serverId,
required EmbyRawItem item,
}) async {
final activeId = ref.read(sessionProvider).value?.activeServerId;
if (serverId != activeId) {
await ref.read(sessionProvider.notifier).switchSession(serverId);
}
if (context.mounted) {
goMediaDetail(context, item);
}
}
Map<String, String> buildEmbyImageHeaders(
WidgetRef ref, {
required String token,
required String userId,
}) {
final deviceId = ref.read(deviceIdProvider).value ?? '';
return EmbyRequestHeaders.image(
deviceId: deviceId,
deviceName: kEmbyDeviceName,
token: token,
userId: userId,
);
}
@@ -0,0 +1,3 @@
const String kEmbyDeviceName = 'Mac';
+91
View File
@@ -0,0 +1,91 @@
class ParsedVersion {
final int major;
final int minor;
final int patch;
final String? prerelease;
final String? build;
const ParsedVersion({
required this.major,
required this.minor,
required this.patch,
this.prerelease,
this.build,
});
bool get hasPrerelease => prerelease != null && prerelease!.isNotEmpty;
bool sameCore(ParsedVersion other) {
return major == other.major && minor == other.minor && patch == other.patch;
}
}
final RegExp _versionPattern = RegExp(
r'^[vV]?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$',
);
ParsedVersion? parseVersion(String tag) {
final match = _versionPattern.firstMatch(tag.trim());
if (match == null) return null;
return ParsedVersion(
major: int.parse(match.group(1)!),
minor: int.parse(match.group(2)!),
patch: int.parse(match.group(3)!),
prerelease: match.group(4),
build: match.group(5),
);
}
bool isNewerVersion(String localTag, String remoteTag) {
final local = parseVersion(localTag);
final remote = parseVersion(remoteTag);
if (local == null || remote == null) return false;
if (remote.hasPrerelease) {
if (!local.hasPrerelease || !local.sameCore(remote)) return false;
return _comparePrerelease(remote.prerelease!, local.prerelease!) > 0;
}
final coreCompare = _compareCore(remote, local);
if (coreCompare != 0) return coreCompare > 0;
return local.hasPrerelease;
}
int _compareCore(ParsedVersion a, ParsedVersion b) {
final major = a.major.compareTo(b.major);
if (major != 0) return major;
final minor = a.minor.compareTo(b.minor);
if (minor != 0) return minor;
return a.patch.compareTo(b.patch);
}
int _comparePrerelease(String a, String b) {
final left = a.split('.');
final right = b.split('.');
final length = left.length > right.length ? left.length : right.length;
for (var i = 0; i < length; i++) {
if (i >= left.length) return -1;
if (i >= right.length) return 1;
final leftPart = left[i];
final rightPart = right[i];
final leftNumber = int.tryParse(leftPart);
final rightNumber = int.tryParse(rightPart);
if (leftNumber != null && rightNumber != null) {
final numberCompare = leftNumber.compareTo(rightNumber);
if (numberCompare != 0) return numberCompare;
continue;
}
if (leftNumber != null) return -1;
if (rightNumber != null) return 1;
final textCompare = leftPart.compareTo(rightPart);
if (textCompare != 0) return textCompare;
}
return 0;
}
+97
View File
@@ -0,0 +1,97 @@
library;
enum TimeBucketScheme { watched, added }
class TimeBucket {
final String key;
final String label;
final int order;
const TimeBucket({
required this.key,
required this.label,
required this.order,
});
}
const _bucketToday = TimeBucket(key: 'today', label: '今天', order: 0);
const _bucketYesterday = TimeBucket(key: 'yesterday', label: '昨天', order: 1);
const _bucketThisWeekWatched = TimeBucket(
key: 'this_week',
label: '本周',
order: 2,
);
const _bucketEarlierWatched = TimeBucket(key: 'earlier', label: '更早', order: 3);
const _bucketThisWeekAdded = TimeBucket(
key: 'this_week',
label: '本周',
order: 1,
);
const _bucketThisMonthAdded = TimeBucket(
key: 'this_month',
label: '本月',
order: 2,
);
const _bucketEarlierAdded = TimeBucket(key: 'earlier', label: '更早', order: 3);
DateTime? parseEmbyDate(Object? raw) {
if (raw is DateTime) return raw.toLocal();
if (raw is! String || raw.isEmpty) return null;
final parsed = DateTime.tryParse(raw);
return parsed?.toLocal();
}
DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day);
DateTime _startOfWeek(DateTime now) {
final today = _dateOnly(now);
return today.subtract(Duration(days: today.weekday - DateTime.monday));
}
TimeBucket bucketFor(DateTime? when, DateTime now, TimeBucketScheme scheme) {
final earlier = scheme == TimeBucketScheme.watched
? _bucketEarlierWatched
: _bucketEarlierAdded;
if (when == null) return earlier;
final whenDay = _dateOnly(when);
final todayDay = _dateOnly(now);
final dayDiff = todayDay.difference(whenDay).inDays;
switch (scheme) {
case TimeBucketScheme.watched:
if (dayDiff <= 0) return _bucketToday;
if (dayDiff == 1) return _bucketYesterday;
if (!whenDay.isBefore(_startOfWeek(now))) return _bucketThisWeekWatched;
return _bucketEarlierWatched;
case TimeBucketScheme.added:
if (dayDiff <= 0) return _bucketToday;
if (!whenDay.isBefore(_startOfWeek(now))) return _bucketThisWeekAdded;
if (whenDay.year == todayDay.year && whenDay.month == todayDay.month) {
return _bucketThisMonthAdded;
}
return _bucketEarlierAdded;
}
}
String? relativeTimeLabel(DateTime? when, DateTime now) {
if (when == null) return null;
final diff = now.difference(when);
if (diff.isNegative) return '刚刚';
if (diff.inMinutes < 1) return '刚刚';
if (diff.inMinutes < 60) return '${diff.inMinutes} 分钟前';
if (diff.inHours < 24) return '${diff.inHours} 小时前';
if (diff.inDays < 7) return '${diff.inDays} 天前';
if (diff.inDays < 35) return '${(diff.inDays / 7).floor()} 周前';
final m = when.month.toString().padLeft(2, '0');
final d = when.day.toString().padLeft(2, '0');
return '${when.year}-$m-$d';
}
+12
View File
@@ -0,0 +1,12 @@
import '../../core/contracts/library.dart';
String? buildTmdbCtaLabel({
required String mediaType,
required TmdbLibraryHit? primaryHit,
required int hitCount,
}) {
if (primaryHit == null) return null;
final hasProgress = (primaryHit.playbackPositionTicks ?? 0) > 0;
return hasProgress ? '继续播放' : '播放';
}
@@ -0,0 +1,36 @@
import '../../core/contracts/library.dart';
import '../../core/contracts/tmdb.dart';
import '../mappers/tmdb_image_url.dart';
int? seasonNumberFrom(EmbyRawSeason season) {
final indexNumber = season.IndexNumber;
if (indexNumber != null) return indexNumber;
final name = season.Name.trim().toLowerCase();
if (name == 'specials' || name == '特别篇' || name == 'sp') return 0;
final match = RegExp(r'\d+').firstMatch(season.Name);
if (match == null) return null;
return int.tryParse(match.group(0)!);
}
Map<int, String> tmdbStillUrlsByEpisode(
TmdbSeasonDetail? season,
String? imageBaseUrl,
) {
if (season == null || imageBaseUrl == null || imageBaseUrl.isEmpty) {
return const {};
}
final urls = <int, String>{};
for (final episode in season.episodes) {
final url = TmdbImageUrl.still(
imageBaseUrl,
episode.stillPath,
size: 'w500',
);
if (url != null && url.isNotEmpty) {
urls[episode.episodeNumber] = url;
}
}
return urls;
}
+28
View File
@@ -0,0 +1,28 @@
import '../../core/contracts/library.dart';
import '../../providers/di_providers.dart';
String tmdbIdFromItem(EmbyRawItem item) {
final rawProviderIds = item.extra['ProviderIds'];
if (rawProviderIds is! Map) return '';
for (final entry in rawProviderIds.entries) {
final key = entry.key?.toString().trim().toLowerCase() ?? '';
final value = entry.value?.toString().trim() ?? '';
if (key == 'tmdb' && value.isNotEmpty) return value;
}
return '';
}
TmdbMediaKey? tmdbMediaKeyFrom(EmbyRawItem item) {
final mediaType = switch (item.Type) {
'Movie' => 'movie',
'Series' || 'Episode' || 'Season' => 'tv',
_ => null,
};
if (mediaType == null) return null;
final tmdbId = tmdbIdFromItem(item);
if (tmdbId.isEmpty) return null;
return (tmdbId: tmdbId, mediaType: mediaType);
}
+27
View File
@@ -0,0 +1,27 @@
import '../../core/contracts/library.dart';
TmdbLibraryHit? selectPrimaryTmdbHit(
List<TmdbLibraryHit> hits, {
String? activeServerId,
}) {
if (hits.isEmpty) return null;
int score(TmdbLibraryHit h) {
var s = 0;
if ((h.playbackPositionTicks ?? 0) > 0) s += 2;
if (activeServerId != null && h.serverId == activeServerId) s += 1;
return s;
}
var best = hits.first;
var bestScore = score(best);
for (final h in hits.skip(1)) {
final s = score(h);
if (s > bestScore) {
best = h;
bestScore = s;
}
}
return best;
}
+1
View File
@@ -0,0 +1 @@
String stripTrailingSlash(String s) => s.replaceFirst(RegExp(r'/+$'), '');
@@ -0,0 +1,13 @@
import '../../core/domain/errors.dart';
String formatUserError(Object? error, {String fallback = '操作失败'}) {
if (error == null) return fallback;
if (error is DomainError) return error.message;
final raw = error.toString().trim();
if (raw.isEmpty) return fallback;
return raw
.replaceFirst(RegExp(r'^(Exception|StateError|TimeoutException):\s*'), '')
.trim();
}
+21
View File
@@ -0,0 +1,21 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'package:window_manager/window_manager.dart';
const String kWinWidthKey = 'window_width';
const String kWinHeightKey = 'window_height';
const String kWinXKey = 'window_x';
const String kWinYKey = 'window_y';
Future<void> saveWindowBounds() async {
try {
final size = await windowManager.getSize();
final position = await windowManager.getPosition();
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(kWinWidthKey, size.width);
await prefs.setDouble(kWinHeightKey, size.height);
await prefs.setDouble(kWinXKey, position.dx);
await prefs.setDouble(kWinYKey, position.dy);
} catch (_) {}
}