import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.enums.swagger.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart' as dto; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/video_properties.dart'; class MediaStreamsModel { final int? versionStreamIndex; final int? defaultAudioStreamIndex; final int? defaultSubStreamIndex; final List versionStreams; MediaStreamsModel({ this.versionStreamIndex, this.defaultAudioStreamIndex, this.defaultSubStreamIndex, required this.versionStreams, }); VersionStreamModel? get currentVersionStream => versionStreams.elementAtOrNull(versionStreamIndex ?? 0); List get videoStreams => currentVersionStream?.videoStreams ?? []; List get audioStreams => currentVersionStream?.audioStreams ?? []; List get subStreams => currentVersionStream?.subStreams ?? []; bool get isNull { return defaultAudioStreamIndex == null || defaultSubStreamIndex == null || audioStreams.isEmpty || subStreams.isEmpty; } bool get isNotEmpty { return audioStreams.isNotEmpty && subStreams.isNotEmpty; } AudioStreamModel? get currentAudioStream { if (defaultAudioStreamIndex == -1 || defaultAudioStreamIndex == null) { return AudioStreamModel.no(); } return audioStreams.firstWhereOrNull((element) => element.index == defaultAudioStreamIndex) ?? audioStreams.firstOrNull; } SubStreamModel? get currentSubStream { if (defaultSubStreamIndex == -1 || defaultSubStreamIndex == null) { return SubStreamModel.no(); } return subStreams.firstWhereOrNull((element) => element.index == defaultSubStreamIndex) ?? subStreams.firstOrNull; } DisplayProfile? get displayProfile { return DisplayProfile.fromVideoStreams(videoStreams); } Resolution? get resolution { return Resolution.fromVideoStream(videoStreams.firstOrNull); } String? get resolutionText { final stream = videoStreams.firstOrNull; if (stream == null) return null; return "${stream.width}x${stream.height}"; } Widget? audioIcon( BuildContext context, Function()? onTap, ) { final audioStream = audioStreams.firstWhereOrNull((element) => element.isDefault) ?? audioStreams.firstOrNull; if (audioStream == null) return null; return DefaultVideoInformationBox( onTap: onTap, child: Text( audioStream.title, ), ); } Widget subtitleIcon( BuildContext context, Function()? onTap, ) { return DefaultVideoInformationBox( onTap: onTap, child: Icon( subStreams.isNotEmpty ? Icons.subtitles_rounded : Icons.subtitles_off_outlined, ), ); } static MediaStreamsModel fromMediaStreamsList( List? mediaSource, Ref ref, ) { return MediaStreamsModel( defaultAudioStreamIndex: mediaSource?.firstOrNull?.defaultAudioStreamIndex, defaultSubStreamIndex: mediaSource?.firstOrNull?.defaultSubtitleStreamIndex, versionStreams: mediaSource?.mapIndexed( (index, element) { final streams = element.mediaStreams ?? []; return VersionStreamModel( name: element.name ?? "", index: index, id: element.id, defaultAudioStreamIndex: element.defaultAudioStreamIndex, defaultSubStreamIndex: element.defaultSubtitleStreamIndex, videoStreams: streams .where((element) => element.type == dto.MediaStreamType.video) .map( (e) => VideoStreamModel.fromMediaStream(e), ) .sortByExternal(), audioStreams: streams .where((element) => element.type == dto.MediaStreamType.audio) .map( (e) => AudioStreamModel.fromMediaStream(e), ) .sortByExternal(), subStreams: streams .where((element) => element.type == dto.MediaStreamType.subtitle) .map( (sub) => SubStreamModel.fromMediaStream(sub, ref), ) .sortByExternal()); }, ).toList() ?? []); } MediaStreamsModel copyWith({ int? versionStreamIndex, int? defaultAudioStreamIndex, int? defaultSubStreamIndex, List? versionStreams, }) { final streamIndexChanged = versionStreamIndex != this.versionStreamIndex && versionStreamIndex != null; final currentVersionStreams = versionStreams ?? this.versionStreams; return MediaStreamsModel( versionStreamIndex: versionStreamIndex ?? this.versionStreamIndex, defaultAudioStreamIndex: streamIndexChanged ? currentVersionStreams.elementAtOrNull(versionStreamIndex)?.defaultAudioStreamIndex : defaultAudioStreamIndex ?? this.defaultAudioStreamIndex, defaultSubStreamIndex: streamIndexChanged ? currentVersionStreams.elementAtOrNull(versionStreamIndex)?.defaultSubStreamIndex : defaultSubStreamIndex ?? this.defaultSubStreamIndex, versionStreams: versionStreams ?? this.versionStreams, ); } @override String toString() { return 'MediaStreamsModel(defaultAudioStreamIndex: $defaultAudioStreamIndex, defaultSubStreamIndex: $defaultSubStreamIndex, videoStreams: $videoStreams, audioStreams: $audioStreams, subStreams: $subStreams)'; } } class StreamModel { final String name; final String codec; final bool isDefault; final bool isExternal; final int index; StreamModel({ required this.name, required this.codec, required this.isDefault, required this.isExternal, required this.index, }); } class AudioAndSubStreamModel extends StreamModel { final String language; final String displayTitle; AudioAndSubStreamModel({ required this.displayTitle, required super.name, required super.codec, required super.isDefault, required super.isExternal, required super.index, required this.language, }); } class VersionStreamModel { final String name; final int index; final String? id; final int? defaultAudioStreamIndex; final int? defaultSubStreamIndex; final List videoStreams; final List audioStreams; final List subStreams; VersionStreamModel({ required this.name, required this.index, this.id, required this.defaultAudioStreamIndex, required this.defaultSubStreamIndex, required this.videoStreams, required this.audioStreams, required this.subStreams, }); } class VideoStreamModel extends StreamModel { final int width; final int height; final int? bitRate; final double frameRate; final String? videoDoViTitle; final VideoRangeType? videoRangeType; VideoStreamModel({ required super.name, required super.codec, required super.isDefault, required super.isExternal, required super.index, required this.videoDoViTitle, required this.videoRangeType, required this.bitRate, required this.width, required this.height, required this.frameRate, }); factory VideoStreamModel.fromMediaStream(dto.MediaStream stream) { return VideoStreamModel( name: stream.title ?? "", isDefault: stream.isDefault ?? false, codec: stream.codec ?? "", videoDoViTitle: stream.videoDoViTitle, bitRate: stream.bitRate, videoRangeType: stream.videoRangeType, width: stream.width ?? 0, height: stream.height ?? 0, frameRate: stream.realFrameRate ?? 24, isExternal: stream.isExternal ?? false, index: stream.index ?? -1, ); } String get prettyName { return "${Resolution.fromVideoStream(this)?.value} - ${DisplayProfile.fromVideoStream(this).value} - (${codec.toUpperCase()})"; } @override String toString() { return 'VideoStreamModel(width: $width, height: $height, frameRate: $frameRate, videoDoViTitle: $videoDoViTitle, videoRangeType: $videoRangeType)'; } } //Instead of using sortBy(a.isExternal etc..) this one seems to be more consistent for some reason extension SortByExternalExtension on Iterable { List sortByExternal() { return [...where((element) => !element.isExternal), ...where((element) => element.isExternal)]; } } class AudioStreamModel extends AudioAndSubStreamModel { final String channelLayout; AudioStreamModel({ required super.displayTitle, required super.name, required super.codec, required super.isDefault, required super.isExternal, required super.index, required super.language, required this.channelLayout, }); factory AudioStreamModel.fromMediaStream(dto.MediaStream stream) { return AudioStreamModel( displayTitle: stream.displayTitle ?? "", name: stream.title ?? "", isDefault: stream.isDefault ?? false, codec: stream.codec ?? "", language: stream.language ?? "Unknown", channelLayout: stream.channelLayout ?? "", isExternal: stream.isExternal ?? false, index: stream.index ?? -1, ); } String label(BuildContext context) { if (index == -1) { return context.localized.off; } else { return displayTitle; } } String get title => [name, language, codec, channelLayout].nonNulls.where((element) => element.isNotEmpty).join(' - '); AudioStreamModel.no({ super.name = 'Off', super.displayTitle = 'Off', super.language = '', super.codec = '', this.channelLayout = '', super.isDefault = false, super.isExternal = false, super.index = -1, }); } class SubStreamModel extends AudioAndSubStreamModel { String id; String title; String? url; bool supportsExternalStream; SubStreamModel({ required super.name, required this.id, required this.title, required super.displayTitle, required super.language, this.url, required super.codec, required super.isDefault, required super.isExternal, required super.index, this.supportsExternalStream = false, }); SubStreamModel.no({ super.name = 'Off', this.id = 'Off', this.title = 'Off', super.displayTitle = 'Off', super.language = '', this.url = '', super.codec = '', super.isDefault = false, super.isExternal = false, super.index = -1, this.supportsExternalStream = false, }); String label(BuildContext context) { if (index == -1) { return context.localized.off; } else { return displayTitle; } } factory SubStreamModel.fromMediaStream(dto.MediaStream stream, Ref ref) { return SubStreamModel( name: stream.title ?? "", title: stream.title ?? "", displayTitle: stream.displayTitle ?? "", language: stream.language ?? "Unknown", isDefault: stream.isDefault ?? false, codec: stream.codec ?? "", id: stream.hashCode.toString(), supportsExternalStream: stream.supportsExternalStream ?? false, url: stream.deliveryUrl != null ? "${ref.read(userProvider)?.server ?? ""}${stream.deliveryUrl}}".replaceAll(".vtt", ".srt") : null, isExternal: stream.isExternal ?? false, index: stream.index ?? -1, ); } SubStreamModel copyWith({ String? name, String? id, String? title, String? displayTitle, String? language, ValueGetter? url, String? codec, bool? isDefault, bool? isExternal, int? index, bool? supportsExternalStream, }) { return SubStreamModel( name: name ?? this.name, id: id ?? this.id, title: title ?? this.title, displayTitle: displayTitle ?? this.displayTitle, language: language ?? this.language, url: url != null ? url() : this.url, supportsExternalStream: supportsExternalStream ?? this.supportsExternalStream, codec: codec ?? this.codec, isDefault: isDefault ?? this.isDefault, isExternal: isExternal ?? this.isExternal, index: index ?? this.index, ); } Map toMap() { return { 'name': name, 'id': id, 'title': title, 'displayTitle': displayTitle, 'language': language, 'url': url, 'supportsExternalStream': supportsExternalStream, 'codec': codec, 'isExternal': isExternal, 'isDefault': isDefault, 'index': index, }; } factory SubStreamModel.fromMap(Map map) { return SubStreamModel( name: map['name'] ?? '', id: map['id'] ?? '', title: map['title'] ?? '', displayTitle: map['displayTitle'] ?? '', language: map['language'] ?? '', url: map['url'], supportsExternalStream: map['supportsExternalStream'] ?? false, codec: map['codec'] ?? '', isDefault: map['isDefault'] ?? false, isExternal: map['isExternal'] ?? false, index: map['index'] ?? -1, ); } String toJson() => json.encode(toMap()); factory SubStreamModel.fromJson(String source) => SubStreamModel.fromMap(json.decode(source)); @override String toString() { return 'SubFile(title: $title, displayTitle: $displayTitle, language: $language, url: $url, isExternal: $isExternal)'; } }