Initial commit
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(',');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 (_) {}
|
||||
}
|
||||
Reference in New Issue
Block a user