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
@@ -0,0 +1,30 @@
import 'package:flutter/widgets.dart';
class DetailHeroModel {
final String title;
final String? logoUrl;
final String? posterUrl;
final double? rating;
final List<String> metaParts;
final List<String> genres;
final String? tagline;
final String? overview;
final Widget? actions;
final Map<String, String>? imageHeaders;
const DetailHeroModel({
required this.title,
this.logoUrl,
this.posterUrl,
this.rating,
this.metaParts = const <String>[],
this.genres = const <String>[],
this.tagline,
this.overview,
this.actions,
this.imageHeaders,
});
}
@@ -0,0 +1,15 @@
class DetailImageModel {
final String? backdropUrl;
final List<String> fallbackUrls;
final String embyBaseUrl;
final Map<String, String>? imageHeaders;
const DetailImageModel({
this.backdropUrl,
this.fallbackUrls = const <String>[],
this.embyBaseUrl = '',
this.imageHeaders,
});
}
@@ -0,0 +1,9 @@
import 'package:flutter/widgets.dart';
class DetailSection {
final String id;
final Widget child;
const DetailSection({required this.id, required this.child});
}
@@ -0,0 +1,29 @@
import 'detail_hero_model.dart';
import 'detail_image_model.dart';
import 'detail_section_model.dart';
class DetailViewState {
final DetailImageModel image;
final String navTitle;
final DetailHeroModel hero;
final List<DetailSection> sections;
final bool showLoadingBelowHero;
const DetailViewState({
required this.image,
required this.navTitle,
required this.hero,
this.sections = const <DetailSection>[],
this.showLoadingBelowHero = false,
});
}
@@ -0,0 +1,34 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/domain/usecases/series_resume_target.dart';
class EpisodeAutoSelectArgs {
final bool autoSelectFirstEpisode;
final String? autoSelectEpisodeId;
final String? autoSelectSeasonId;
const EpisodeAutoSelectArgs({
required this.autoSelectFirstEpisode,
required this.autoSelectEpisodeId,
required this.autoSelectSeasonId,
});
factory EpisodeAutoSelectArgs.fromResume(
AsyncValue<SeriesResumeTarget?>? resumeAsync,
) {
final loading = resumeAsync?.isLoading ?? false;
final target = resumeAsync?.value;
return EpisodeAutoSelectArgs(
autoSelectFirstEpisode: !loading,
autoSelectEpisodeId: target?.episodeId,
autoSelectSeasonId: target?.seasonId,
);
}
}
@@ -0,0 +1,38 @@
import '../../../core/contracts/library.dart';
EmbyRawItem mergeSelectedEpisodeDetail({
required EmbyRawItem detail,
required EmbyRawItem listItem,
}) {
final mergedRunTime = detail.RunTimeTicks ?? listItem.RunTimeTicks;
final mergedUserData = _mergeUserData(detail.UserData, listItem.UserData);
if (mergedRunTime == detail.RunTimeTicks &&
identical(mergedUserData, detail.UserData)) {
return detail;
}
return detail.copyWith(RunTimeTicks: mergedRunTime, UserData: mergedUserData);
}
EmbyRawUserData? _mergeUserData(
EmbyRawUserData? detail,
EmbyRawUserData? listItem,
) {
if (listItem == null) return detail;
if (detail == null) return listItem;
if (detail.PlaybackPositionTicks == null &&
listItem.PlaybackPositionTicks != null) {
return detail.copyWith(
PlaybackPositionTicks: listItem.PlaybackPositionTicks,
);
}
return detail;
}
EmbyRawItem applyPlaybackReturnProgress(EmbyRawItem item, int? positionTicks) {
if (positionTicks == null || positionTicks <= 0) return item;
final userData = (item.UserData ?? const EmbyRawUserData()).copyWith(
PlaybackPositionTicks: positionTicks,
);
return item.copyWith(UserData: userData);
}
@@ -0,0 +1,82 @@
import '../../../core/contracts/library.dart';
import '../../../core/infra/emby_api/playback_resolver.dart';
class StreamSelection {
final List<EmbyRawMediaStream> subtitles;
final List<EmbyRawMediaStream> audios;
final int preferredSubtitleStreamIndex;
final int? preferredAudioStreamIndex;
const StreamSelection({
required this.subtitles,
required this.audios,
required this.preferredSubtitleStreamIndex,
required this.preferredAudioStreamIndex,
});
}
EmbyRawMediaSource? resolveActiveMediaSource(
CrossServerSourceCard? selectedCrossServerCard,
List<EmbyRawMediaSource> localSources,
int selectedSourceIdx,
) {
return selectedCrossServerCard?.mediaSource ??
(selectedSourceIdx < localSources.length
? localSources[selectedSourceIdx]
: null);
}
StreamSelection resolveStreamSelection(
EmbyRawMediaSource? source,
int? selectedSubtitleIdx,
int selectedAudioIdx,
) {
final subtitles = (source?.MediaStreams ?? [])
.where((s) => s.Type == 'Subtitle')
.where((s) {
final isExternal =
(s.IsExternal ?? false) ||
s.DeliveryMethod == 'External' ||
(s.DeliveryUrl != null && s.DeliveryUrl!.isNotEmpty);
if (!isExternal) return true;
return PlaybackResolver.isTextSubtitleCodec(s.Codec);
})
.toList();
final audios = (source?.MediaStreams ?? [])
.where((s) => s.Type == 'Audio')
.toList();
final preferredSubtitleStreamIndex =
selectedSubtitleIdx != null && selectedSubtitleIdx < subtitles.length
? subtitles[selectedSubtitleIdx].Index ?? -1
: -1;
final preferredAudioStreamIndex = selectedAudioIdx < audios.length
? audios[selectedAudioIdx].Index
: null;
return StreamSelection(
subtitles: subtitles,
audios: audios,
preferredSubtitleStreamIndex: preferredSubtitleStreamIndex,
preferredAudioStreamIndex: preferredAudioStreamIndex,
);
}
int resolveDefaultSourceIndex(
List<EmbyRawMediaSource> sortedSources,
String? rememberedMediaSourceId, {
int fallback = 0,
}) {
if (rememberedMediaSourceId == null) return fallback;
final idx = sortedSources.indexWhere((s) => s.Id == rememberedMediaSourceId);
return idx >= 0 ? idx : fallback;
}