From 0ef71e64890d84028b1588f55bafd97483e35dc9 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 29 Jul 2026 05:18:56 +0200 Subject: [PATCH] feat(file-info): detail every version, file and stream the server reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sheet collapsed an item to `Media.first` / `MediaSources.first` and rendered a fixed set of rows, so split files, extra versions, per-track properties, HDR classification and Dolby Vision were all invisible. Model the payload the way both servers shape it — versions own parts, parts own streams — and project every property either backend populates onto `MediaStreamDetails`. The field set comes from sweeping both test servers in full through the clients' own request shapes (Plex 6842 Media / 6842 Part / 39864 Stream entries, Jellyfin 6349 sources / 37763 streams / 61224 attachments), so file presence, Dolby Vision layers, dynamic range, sample rate, spatial audio, sidecar provenance, embedded attachments, rotation and the lyric stream type all survive. A coverage test fails when a server key is neither carried, folded into a sibling, nor excluded with a reason. File Info also stopped trusting the shared `/library/metadata/{id}` cache row: `getPlaybackExtras` writes it without `includeStreams` / `checkFiles`, so the sheet could render with no stream table at all. Detect that shape and refetch once under the request context captured before the cache read, so the outgoing token and the cache namespace stay on one profile. Rework the layout to match: a summary chip row, then flat tonal cards with a two-column field grid that collapses to one column on narrow viewports, per-stream cards with flag chips, and a copyable monospace path row. The card fill is a tonal step off the text colour rather than the `bg` token, which is one shade from the sheet surface on OLED. --- lib/i18n/az.i18n.json | 94 +- lib/i18n/bg.i18n.json | 94 +- lib/i18n/da.i18n.json | 94 +- lib/i18n/de.i18n.json | 94 +- lib/i18n/en.i18n.json | 94 +- lib/i18n/es.i18n.json | 94 +- lib/i18n/fr.i18n.json | 94 +- lib/i18n/hu.i18n.json | 94 +- lib/i18n/it.i18n.json | 94 +- lib/i18n/ja.i18n.json | 94 +- lib/i18n/kk.i18n.json | 94 +- lib/i18n/ko.i18n.json | 94 +- lib/i18n/nb.i18n.json | 94 +- lib/i18n/nl.i18n.json | 94 +- lib/i18n/pl.i18n.json | 94 +- lib/i18n/pt.i18n.json | 94 +- lib/i18n/ru.i18n.json | 94 +- lib/i18n/strings.g.dart | 2 +- lib/i18n/strings_az.g.dart | 10 +- lib/i18n/strings_bg.g.dart | 10 +- lib/i18n/strings_da.g.dart | 10 +- lib/i18n/strings_de.g.dart | 10 +- lib/i18n/strings_en.g.dart | 374 ++++++- lib/i18n/strings_es.g.dart | 10 +- lib/i18n/strings_fr.g.dart | 10 +- lib/i18n/strings_hu.g.dart | 10 +- lib/i18n/strings_it.g.dart | 10 +- lib/i18n/strings_ja.g.dart | 10 +- lib/i18n/strings_kk.g.dart | 10 +- lib/i18n/strings_ko.g.dart | 10 +- lib/i18n/strings_nb.g.dart | 10 +- lib/i18n/strings_nl.g.dart | 10 +- lib/i18n/strings_pl.g.dart | 10 +- lib/i18n/strings_pt.g.dart | 10 +- lib/i18n/strings_ru.g.dart | 10 +- lib/i18n/strings_sv.g.dart | 10 +- lib/i18n/strings_tr.g.dart | 10 +- lib/i18n/strings_uz.g.dart | 10 +- lib/i18n/strings_zh.g.dart | 10 +- lib/i18n/strings_zh_Hant.g.dart | 10 +- lib/i18n/sv.i18n.json | 94 +- lib/i18n/tr.i18n.json | 94 +- lib/i18n/uz.i18n.json | 94 +- lib/i18n/zh-Hant.i18n.json | 94 +- lib/i18n/zh.i18n.json | 94 +- lib/media/media_file_info.dart | 643 +++++++++-- lib/media/media_server_client.dart | 13 +- lib/services/file_info_parser.dart | 259 +++++ .../jellyfin_client/parts/file_info.dart | 194 ++-- lib/services/plex_client.dart | 62 +- lib/services/plex_playback_mapper.dart | 133 ++- lib/widgets/file_info_bottom_sheet.dart | 785 ++++++++++++-- lib/widgets/stat_chip.dart | 9 +- .../services/file_info_key_coverage_test.dart | 997 ++++++++++++++++++ .../media_file_info_parsers_test.dart | 560 ++++++++++ test/services/plex_file_info_cache_test.dart | 250 +++++ test/services/plex_playback_mapper_test.dart | 44 +- test/widgets/file_info_bottom_sheet_test.dart | 341 ++++++ 58 files changed, 6429 insertions(+), 515 deletions(-) create mode 100644 test/services/file_info_key_coverage_test.dart create mode 100644 test/services/media_file_info_parsers_test.dart create mode 100644 test/services/plex_file_info_cache_test.dart create mode 100644 test/widgets/file_info_bottom_sheet_test.dart diff --git a/lib/i18n/az.i18n.json b/lib/i18n/az.i18n.json index f5079534..759228ea 100644 --- a/lib/i18n/az.i18n.json +++ b/lib/i18n/az.i18n.json @@ -381,30 +381,118 @@ }, "fileInfo": { "title": "Fayl məlumatı", + "overview": "", "video": "Video", "audio": "Səs", + "subtitles": "Altyazılar", + "images": "", + "dataStreams": "", + "lyrics": "", "file": "Fayl", - "advanced": "Təkmilləşdirilmiş", + "attachments": "", + "delivery": "", + "versionCounter": "", + "fileCounter": "", + "noStreams": "", + "copyPath": "", + "pathCopied": "", "codec": "Kodek", + "codecTag": "", "resolution": "Ayırdetmə", + "codedResolution": "", "bitrate": "Bit sürəti (Bitrate)", "frameRate": "Kadr tezliyi", + "rotation": "", + "comment": "", + "audioDescription": "", + "headerCompression": "", + "sidecarFile": "", + "transportTimestamp": "", + "displayOffset": "", + "previewFailureCode": "", + "previewRetries": "", "aspectRatio": "Tərəf nisbəti", + "pixelAspectRatio": "", "profile": "Profil", + "level": "", "bitDepth": "Bit dərinliyi", + "pixelFormat": "", "colorSpace": "Rəng sahəsi", "colorRange": "Rəng diapazonu", "colorPrimaries": "Əsas rənglər", + "colorTransfer": "", "chromaSubsampling": "Rəng alt-diskretləşdirməsi", + "chromaLocation": "", + "scanType": "", + "interlaced": "", + "anamorphic": "", + "referenceFrames": "", + "dynamicRange": "", + "dolbyVision": "", + "dolbyVisionLevel": "", + "dolbyVisionVersion": "", + "dolbyVisionLayers": "", + "baseLayerCompatibility": "", + "avcBitstream": "", + "nalLengthSize": "", + "scalingMatrix": "", + "streamIdentifier": "", + "streamIndex": "", + "streamId": "", + "language": "", + "languageCode": "", + "streamTitle": "", "channels": "Kanallar", - "subtitles": "Altyazılar", + "sampleRate": "", + "spatialAudio": "", + "textBased": "", + "subtitleFormat": "", + "provider": "", + "matchScore": "", + "externalDelivery": "", + "sidecarPath": "", + "sourceStream": "", + "temporary": "", + "timeBase": "", "overallBitrate": "Ümumi bit sürəti", "path": "Yol", + "fileName": "", "size": "Həcm", + "totalSize": "", "container": "Konteyner", "duration": "Müddət", + "previewThumbnails": "", + "previewIndex": "", + "packetLength": "", + "filePresent": "", + "fileReadable": "", + "streamPath": "", "optimizedForStreaming": "Yayım üçün optimallaşdırılıb", - "has64bitOffsets": "64-bit ofsetlər" + "has64bitOffsets": "64-bit ofsetlər", + "protocol": "", + "mediaType": "", + "sourceKind": "", + "optimizedVersion": "", + "optimizationTarget": "", + "deletedAt": "", + "remoteSource": "", + "infiniteStream": "", + "directPlay": "", + "directStream": "", + "transcoding": "", + "etag": "", + "versionId": "", + "fileId": "", + "defaultAudioTrack": "", + "defaultSubtitleTrack": "", + "subtitlesOff": "", + "flagDefault": "", + "flagForced": "", + "flagSelected": "", + "flagExternal": "", + "flagHearingImpaired": "", + "flagDub": "", + "flagOriginal": "" }, "mediaMenu": { "markAsWatched": "Baxıldı olaraq işarələ", diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index bea3776a..be13ed03 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -381,30 +381,118 @@ }, "fileInfo": { "title": "Информация за файла", + "overview": "", "video": "Видео", "audio": "Аудио", + "subtitles": "Субтитри", + "images": "", + "dataStreams": "", + "lyrics": "", "file": "Файл", - "advanced": "Разширени", + "attachments": "", + "delivery": "", + "versionCounter": "", + "fileCounter": "", + "noStreams": "", + "copyPath": "", + "pathCopied": "", "codec": "Кодек", + "codecTag": "", "resolution": "Резолюция", + "codedResolution": "", "bitrate": "Битрейт", "frameRate": "Кадрова честота", + "rotation": "", + "comment": "", + "audioDescription": "", + "headerCompression": "", + "sidecarFile": "", + "transportTimestamp": "", + "displayOffset": "", + "previewFailureCode": "", + "previewRetries": "", "aspectRatio": "Съотношение на страните", + "pixelAspectRatio": "", "profile": "Профил", + "level": "", "bitDepth": "Битова дълбочина", + "pixelFormat": "", "colorSpace": "Цветово пространство", "colorRange": "Цветови диапазон", "colorPrimaries": "Основни цветове", + "colorTransfer": "", "chromaSubsampling": "Цветова субдискретизация", + "chromaLocation": "", + "scanType": "", + "interlaced": "", + "anamorphic": "", + "referenceFrames": "", + "dynamicRange": "", + "dolbyVision": "", + "dolbyVisionLevel": "", + "dolbyVisionVersion": "", + "dolbyVisionLayers": "", + "baseLayerCompatibility": "", + "avcBitstream": "", + "nalLengthSize": "", + "scalingMatrix": "", + "streamIdentifier": "", + "streamIndex": "", + "streamId": "", + "language": "", + "languageCode": "", + "streamTitle": "", "channels": "Канали", - "subtitles": "Субтитри", + "sampleRate": "", + "spatialAudio": "", + "textBased": "", + "subtitleFormat": "", + "provider": "", + "matchScore": "", + "externalDelivery": "", + "sidecarPath": "", + "sourceStream": "", + "temporary": "", + "timeBase": "", "overallBitrate": "Общ битрейт", "path": "Път", + "fileName": "", "size": "Размер", + "totalSize": "", "container": "Контейнер", "duration": "Продължителност", + "previewThumbnails": "", + "previewIndex": "", + "packetLength": "", + "filePresent": "", + "fileReadable": "", + "streamPath": "", "optimizedForStreaming": "Оптимизирано за стрийминг", - "has64bitOffsets": "64-битови отмествания" + "has64bitOffsets": "64-битови отмествания", + "protocol": "", + "mediaType": "", + "sourceKind": "", + "optimizedVersion": "", + "optimizationTarget": "", + "deletedAt": "", + "remoteSource": "", + "infiniteStream": "", + "directPlay": "", + "directStream": "", + "transcoding": "", + "etag": "", + "versionId": "", + "fileId": "", + "defaultAudioTrack": "", + "defaultSubtitleTrack": "", + "subtitlesOff": "", + "flagDefault": "", + "flagForced": "", + "flagSelected": "", + "flagExternal": "", + "flagHearingImpaired": "", + "flagDub": "", + "flagOriginal": "" }, "mediaMenu": { "markAsWatched": "Маркирай като гледано", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 2ad9c712..ccec3676 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -381,30 +381,118 @@ }, "fileInfo": { "title": "Filinfo", + "overview": "", "video": "Video", "audio": "Lyd", + "subtitles": "Undertekster", + "images": "", + "dataStreams": "", + "lyrics": "", "file": "Fil", - "advanced": "Avanceret", + "attachments": "", + "delivery": "", + "versionCounter": "", + "fileCounter": "", + "noStreams": "", + "copyPath": "", + "pathCopied": "", "codec": "Codec", + "codecTag": "", "resolution": "Opløsning", + "codedResolution": "", "bitrate": "Bitrate", "frameRate": "Billedhastighed", + "rotation": "", + "comment": "", + "audioDescription": "", + "headerCompression": "", + "sidecarFile": "", + "transportTimestamp": "", + "displayOffset": "", + "previewFailureCode": "", + "previewRetries": "", "aspectRatio": "Billedformat", + "pixelAspectRatio": "", "profile": "Profil", + "level": "", "bitDepth": "Bitdybde", + "pixelFormat": "", "colorSpace": "Farverum", "colorRange": "Farveområde", "colorPrimaries": "Farveprimærer", + "colorTransfer": "", "chromaSubsampling": "Chroma-subsampling", + "chromaLocation": "", + "scanType": "", + "interlaced": "", + "anamorphic": "", + "referenceFrames": "", + "dynamicRange": "", + "dolbyVision": "", + "dolbyVisionLevel": "", + "dolbyVisionVersion": "", + "dolbyVisionLayers": "", + "baseLayerCompatibility": "", + "avcBitstream": "", + "nalLengthSize": "", + "scalingMatrix": "", + "streamIdentifier": "", + "streamIndex": "", + "streamId": "", + "language": "", + "languageCode": "", + "streamTitle": "", "channels": "Kanaler", - "subtitles": "Undertekster", + "sampleRate": "", + "spatialAudio": "", + "textBased": "", + "subtitleFormat": "", + "provider": "", + "matchScore": "", + "externalDelivery": "", + "sidecarPath": "", + "sourceStream": "", + "temporary": "", + "timeBase": "", "overallBitrate": "Samlet bitrate", "path": "Sti", + "fileName": "", "size": "Størrelse", + "totalSize": "", "container": "Container", "duration": "Varighed", + "previewThumbnails": "", + "previewIndex": "", + "packetLength": "", + "filePresent": "", + "fileReadable": "", + "streamPath": "", "optimizedForStreaming": "Optimeret til streaming", - "has64bitOffsets": "64-bit-forskydninger" + "has64bitOffsets": "64-bit-forskydninger", + "protocol": "", + "mediaType": "", + "sourceKind": "", + "optimizedVersion": "", + "optimizationTarget": "", + "deletedAt": "", + "remoteSource": "", + "infiniteStream": "", + "directPlay": "", + "directStream": "", + "transcoding": "", + "etag": "", + "versionId": "", + "fileId": "", + "defaultAudioTrack": "", + "defaultSubtitleTrack": "", + "subtitlesOff": "", + "flagDefault": "", + "flagForced": "", + "flagSelected": "", + "flagExternal": "", + "flagHearingImpaired": "", + "flagDub": "", + "flagOriginal": "" }, "mediaMenu": { "markAsWatched": "Markér som set", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index c8fd7993..210b2451 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -381,30 +381,118 @@ }, "fileInfo": { "title": "Dateiinformationen", + "overview": "", "video": "Video", "audio": "Audio", + "subtitles": "Untertitel", + "images": "", + "dataStreams": "", + "lyrics": "", "file": "Datei", - "advanced": "Erweitert", + "attachments": "", + "delivery": "", + "versionCounter": "", + "fileCounter": "", + "noStreams": "", + "copyPath": "", + "pathCopied": "", "codec": "Codec", + "codecTag": "", "resolution": "Auflösung", + "codedResolution": "", "bitrate": "Bitrate", "frameRate": "Bildrate", + "rotation": "", + "comment": "", + "audioDescription": "", + "headerCompression": "", + "sidecarFile": "", + "transportTimestamp": "", + "displayOffset": "", + "previewFailureCode": "", + "previewRetries": "", "aspectRatio": "Seitenverhältnis", + "pixelAspectRatio": "", "profile": "Profil", + "level": "", "bitDepth": "Bittiefe", + "pixelFormat": "", "colorSpace": "Farbraum", "colorRange": "Farbbereich", "colorPrimaries": "Primärfarben", + "colorTransfer": "", "chromaSubsampling": "Chroma-Subsampling", + "chromaLocation": "", + "scanType": "", + "interlaced": "", + "anamorphic": "", + "referenceFrames": "", + "dynamicRange": "", + "dolbyVision": "", + "dolbyVisionLevel": "", + "dolbyVisionVersion": "", + "dolbyVisionLayers": "", + "baseLayerCompatibility": "", + "avcBitstream": "", + "nalLengthSize": "", + "scalingMatrix": "", + "streamIdentifier": "", + "streamIndex": "", + "streamId": "", + "language": "", + "languageCode": "", + "streamTitle": "", "channels": "Kanäle", - "subtitles": "Untertitel", + "sampleRate": "", + "spatialAudio": "", + "textBased": "", + "subtitleFormat": "", + "provider": "", + "matchScore": "", + "externalDelivery": "", + "sidecarPath": "", + "sourceStream": "", + "temporary": "", + "timeBase": "", "overallBitrate": "Gesamtbitrate", "path": "Pfad", + "fileName": "", "size": "Größe", + "totalSize": "", "container": "Container", "duration": "Dauer", + "previewThumbnails": "", + "previewIndex": "", + "packetLength": "", + "filePresent": "", + "fileReadable": "", + "streamPath": "", "optimizedForStreaming": "Für Streaming optimiert", - "has64bitOffsets": "64-Bit-Offsets" + "has64bitOffsets": "64-Bit-Offsets", + "protocol": "", + "mediaType": "", + "sourceKind": "", + "optimizedVersion": "", + "optimizationTarget": "", + "deletedAt": "", + "remoteSource": "", + "infiniteStream": "", + "directPlay": "", + "directStream": "", + "transcoding": "", + "etag": "", + "versionId": "", + "fileId": "", + "defaultAudioTrack": "", + "defaultSubtitleTrack": "", + "subtitlesOff": "", + "flagDefault": "", + "flagForced": "", + "flagSelected": "", + "flagExternal": "", + "flagHearingImpaired": "", + "flagDub": "", + "flagOriginal": "" }, "mediaMenu": { "markAsWatched": "Als gesehen markieren", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index fc0c74f2..9051ae96 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -381,30 +381,118 @@ }, "fileInfo": { "title": "File Info", + "overview": "Overview", "video": "Video", "audio": "Audio", + "subtitles": "Subtitles", + "images": "Embedded Images", + "dataStreams": "Data Streams", + "lyrics": "Lyrics", "file": "File", - "advanced": "Advanced", + "attachments": "Attachments", + "delivery": "Delivery", + "versionCounter": "Version ${index} of ${count}", + "fileCounter": "File ${index} of ${count}", + "noStreams": "The server reported no streams for this file.", + "copyPath": "Copy path", + "pathCopied": "File path copied", "codec": "Codec", + "codecTag": "Codec Tag", "resolution": "Resolution", + "codedResolution": "Coded Resolution", "bitrate": "Bitrate", "frameRate": "Frame Rate", + "rotation": "Rotation", + "comment": "Comment", + "audioDescription": "Audio Description", + "headerCompression": "Header Compression", + "sidecarFile": "Sidecar File", + "transportTimestamp": "Transport Timestamp", + "displayOffset": "Display Offset", + "previewFailureCode": "Preview Failure Code", + "previewRetries": "Preview Retries", "aspectRatio": "Aspect Ratio", + "pixelAspectRatio": "Pixel Aspect Ratio", "profile": "Profile", + "level": "Level", "bitDepth": "Bit Depth", + "pixelFormat": "Pixel Format", "colorSpace": "Color Space", "colorRange": "Color Range", "colorPrimaries": "Color Primaries", + "colorTransfer": "Color Transfer", "chromaSubsampling": "Chroma Subsampling", + "chromaLocation": "Chroma Location", + "scanType": "Scan Type", + "interlaced": "Interlaced", + "anamorphic": "Anamorphic", + "referenceFrames": "Reference Frames", + "dynamicRange": "Dynamic Range", + "dolbyVision": "Dolby Vision", + "dolbyVisionLevel": "Dolby Vision Level", + "dolbyVisionVersion": "Dolby Vision Version", + "dolbyVisionLayers": "Dolby Vision Layers", + "baseLayerCompatibility": "Base Layer Compatibility", + "avcBitstream": "AVC Bitstream", + "nalLengthSize": "NAL Length Size", + "scalingMatrix": "Custom Scaling Matrix", + "streamIdentifier": "Stream Identifier", + "streamIndex": "Stream Index", + "streamId": "Stream ID", + "language": "Language", + "languageCode": "Language Code", + "streamTitle": "Track Title", "channels": "Channels", - "subtitles": "Subtitles", + "sampleRate": "Sample Rate", + "spatialAudio": "Spatial Audio", + "textBased": "Text Based", + "subtitleFormat": "Sidecar Format", + "provider": "Provider", + "matchScore": "Match Score", + "externalDelivery": "Can Be Served Separately", + "sidecarPath": "Sidecar Path", + "sourceStream": "Copied From", + "temporary": "Temporary", + "timeBase": "Time Base", "overallBitrate": "Overall Bitrate", "path": "Path", + "fileName": "File Name", "size": "Size", + "totalSize": "Total Size", "container": "Container", "duration": "Duration", + "previewThumbnails": "Preview Thumbnails", + "previewIndex": "Preview Index", + "packetLength": "Packet Length", + "filePresent": "File Present", + "fileReadable": "Readable by Server", + "streamPath": "Stream Path", "optimizedForStreaming": "Optimized for Streaming", - "has64bitOffsets": "64-bit Offsets" + "has64bitOffsets": "64-bit Offsets", + "protocol": "Protocol", + "mediaType": "Media Type", + "sourceKind": "Source Kind", + "optimizedVersion": "Optimized Version", + "optimizationTarget": "Optimization Target", + "deletedAt": "Deleted", + "remoteSource": "Remote Source", + "infiniteStream": "Infinite Stream", + "directPlay": "Direct Play", + "directStream": "Direct Stream", + "transcoding": "Transcoding", + "etag": "ETag", + "versionId": "Version ID", + "fileId": "File ID", + "defaultAudioTrack": "Default Audio Track", + "defaultSubtitleTrack": "Default Subtitle Track", + "subtitlesOff": "Off", + "flagDefault": "Default", + "flagForced": "Forced", + "flagSelected": "Selected", + "flagExternal": "External", + "flagHearingImpaired": "Hearing impaired", + "flagDub": "Dub", + "flagOriginal": "Original" }, "mediaMenu": { "markAsWatched": "Mark as Watched", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 2ae0cf37..86ffa7bf 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -381,30 +381,118 @@ }, "fileInfo": { "title": "Información del archivo", + "overview": "", "video": "Video", "audio": "Audio", + "subtitles": "Subtítulos", + "images": "", + "dataStreams": "", + "lyrics": "", "file": "Archivo", - "advanced": "Avanzado", + "attachments": "", + "delivery": "", + "versionCounter": "", + "fileCounter": "", + "noStreams": "", + "copyPath": "", + "pathCopied": "", "codec": "Códec", + "codecTag": "", "resolution": "Resolución", + "codedResolution": "", "bitrate": "Tasa de bits", "frameRate": "Frecuencia de fotogramas", + "rotation": "", + "comment": "", + "audioDescription": "", + "headerCompression": "", + "sidecarFile": "", + "transportTimestamp": "", + "displayOffset": "", + "previewFailureCode": "", + "previewRetries": "", "aspectRatio": "Relación de aspecto", + "pixelAspectRatio": "", "profile": "Perfil", + "level": "", "bitDepth": "Profundidad de bits", + "pixelFormat": "", "colorSpace": "Espacio de color", "colorRange": "Rango de color", "colorPrimaries": "Primarias de color", + "colorTransfer": "", "chromaSubsampling": "Submuestreo de croma", + "chromaLocation": "", + "scanType": "", + "interlaced": "", + "anamorphic": "", + "referenceFrames": "", + "dynamicRange": "", + "dolbyVision": "", + "dolbyVisionLevel": "", + "dolbyVisionVersion": "", + "dolbyVisionLayers": "", + "baseLayerCompatibility": "", + "avcBitstream": "", + "nalLengthSize": "", + "scalingMatrix": "", + "streamIdentifier": "", + "streamIndex": "", + "streamId": "", + "language": "", + "languageCode": "", + "streamTitle": "", "channels": "Canales", - "subtitles": "Subtítulos", + "sampleRate": "", + "spatialAudio": "", + "textBased": "", + "subtitleFormat": "", + "provider": "", + "matchScore": "", + "externalDelivery": "", + "sidecarPath": "", + "sourceStream": "", + "temporary": "", + "timeBase": "", "overallBitrate": "Tasa de bits total", "path": "Ruta", + "fileName": "", "size": "Tamaño", + "totalSize": "", "container": "Contenedor", "duration": "Duración", + "previewThumbnails": "", + "previewIndex": "", + "packetLength": "", + "filePresent": "", + "fileReadable": "", + "streamPath": "", "optimizedForStreaming": "Optimizado para transmisión", - "has64bitOffsets": "Desplazamientos de 64 bits" + "has64bitOffsets": "Desplazamientos de 64 bits", + "protocol": "", + "mediaType": "", + "sourceKind": "", + "optimizedVersion": "", + "optimizationTarget": "", + "deletedAt": "", + "remoteSource": "", + "infiniteStream": "", + "directPlay": "", + "directStream": "", + "transcoding": "", + "etag": "", + "versionId": "", + "fileId": "", + "defaultAudioTrack": "", + "defaultSubtitleTrack": "", + "subtitlesOff": "", + "flagDefault": "", + "flagForced": "", + "flagSelected": "", + "flagExternal": "", + "flagHearingImpaired": "", + "flagDub": "", + "flagOriginal": "" }, "mediaMenu": { "markAsWatched": "Marcar como visto", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index d31be3fa..4de2d2a3 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -381,30 +381,118 @@ }, "fileInfo": { "title": "Informations sur le fichier", + "overview": "", "video": "Vidéo", "audio": "Audio", + "subtitles": "Sous-titres", + "images": "", + "dataStreams": "", + "lyrics": "", "file": "Fichier", - "advanced": "Avancé", + "attachments": "", + "delivery": "", + "versionCounter": "", + "fileCounter": "", + "noStreams": "", + "copyPath": "", + "pathCopied": "", "codec": "Codec", + "codecTag": "", "resolution": "Résolution", + "codedResolution": "", "bitrate": "Débit", "frameRate": "Fréquence d'images", + "rotation": "", + "comment": "", + "audioDescription": "", + "headerCompression": "", + "sidecarFile": "", + "transportTimestamp": "", + "displayOffset": "", + "previewFailureCode": "", + "previewRetries": "", "aspectRatio": "Format d'image", + "pixelAspectRatio": "", "profile": "Profil", + "level": "", "bitDepth": "Profondeur de bits", + "pixelFormat": "", "colorSpace": "Espace colorimétrique", "colorRange": "Gamme de couleurs", "colorPrimaries": "Couleurs primaires", + "colorTransfer": "", "chromaSubsampling": "Sous-échantillonnage chromatique", + "chromaLocation": "", + "scanType": "", + "interlaced": "", + "anamorphic": "", + "referenceFrames": "", + "dynamicRange": "", + "dolbyVision": "", + "dolbyVisionLevel": "", + "dolbyVisionVersion": "", + "dolbyVisionLayers": "", + "baseLayerCompatibility": "", + "avcBitstream": "", + "nalLengthSize": "", + "scalingMatrix": "", + "streamIdentifier": "", + "streamIndex": "", + "streamId": "", + "language": "", + "languageCode": "", + "streamTitle": "", "channels": "Canaux", - "subtitles": "Sous-titres", + "sampleRate": "", + "spatialAudio": "", + "textBased": "", + "subtitleFormat": "", + "provider": "", + "matchScore": "", + "externalDelivery": "", + "sidecarPath": "", + "sourceStream": "", + "temporary": "", + "timeBase": "", "overallBitrate": "Débit global", "path": "Chemin", + "fileName": "", "size": "Taille", + "totalSize": "", "container": "Conteneur", "duration": "Durée", + "previewThumbnails": "", + "previewIndex": "", + "packetLength": "", + "filePresent": "", + "fileReadable": "", + "streamPath": "", "optimizedForStreaming": "Optimisé pour le streaming", - "has64bitOffsets": "Décalages 64 bits" + "has64bitOffsets": "Décalages 64 bits", + "protocol": "", + "mediaType": "", + "sourceKind": "", + "optimizedVersion": "", + "optimizationTarget": "", + "deletedAt": "", + "remoteSource": "", + "infiniteStream": "", + "directPlay": "", + "directStream": "", + "transcoding": "", + "etag": "", + "versionId": "", + "fileId": "", + "defaultAudioTrack": "", + "defaultSubtitleTrack": "", + "subtitlesOff": "", + "flagDefault": "", + "flagForced": "", + "flagSelected": "", + "flagExternal": "", + "flagHearingImpaired": "", + "flagDub": "", + "flagOriginal": "" }, "mediaMenu": { "markAsWatched": "Marquer comme vu", diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json index da0534c5..cd903f41 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -381,30 +381,118 @@ }, "fileInfo": { "title": "Fájlinformáció", + "overview": "", "video": "Videó", "audio": "Hang", + "subtitles": "Feliratok", + "images": "", + "dataStreams": "", + "lyrics": "", "file": "Fájl", - "advanced": "Haladó", + "attachments": "", + "delivery": "", + "versionCounter": "", + "fileCounter": "", + "noStreams": "", + "copyPath": "", + "pathCopied": "", "codec": "Kodek", + "codecTag": "", "resolution": "Felbontás", + "codedResolution": "", "bitrate": "Bitráta", "frameRate": "Képkockasebesség", + "rotation": "", + "comment": "", + "audioDescription": "", + "headerCompression": "", + "sidecarFile": "", + "transportTimestamp": "", + "displayOffset": "", + "previewFailureCode": "", + "previewRetries": "", "aspectRatio": "Méretarány", + "pixelAspectRatio": "", "profile": "Profil", + "level": "", "bitDepth": "Bitmélység", + "pixelFormat": "", "colorSpace": "Színtér", "colorRange": "Színtartomány", "colorPrimaries": "Elsődleges színek", + "colorTransfer": "", "chromaSubsampling": "Krominancia-alulmintavételezés", + "chromaLocation": "", + "scanType": "", + "interlaced": "", + "anamorphic": "", + "referenceFrames": "", + "dynamicRange": "", + "dolbyVision": "", + "dolbyVisionLevel": "", + "dolbyVisionVersion": "", + "dolbyVisionLayers": "", + "baseLayerCompatibility": "", + "avcBitstream": "", + "nalLengthSize": "", + "scalingMatrix": "", + "streamIdentifier": "", + "streamIndex": "", + "streamId": "", + "language": "", + "languageCode": "", + "streamTitle": "", "channels": "Csatornák", - "subtitles": "Feliratok", + "sampleRate": "", + "spatialAudio": "", + "textBased": "", + "subtitleFormat": "", + "provider": "", + "matchScore": "", + "externalDelivery": "", + "sidecarPath": "", + "sourceStream": "", + "temporary": "", + "timeBase": "", "overallBitrate": "Összesített bitráta", "path": "Elérési út", + "fileName": "", "size": "Méret", + "totalSize": "", "container": "Konténer", "duration": "Időtartam", + "previewThumbnails": "", + "previewIndex": "", + "packetLength": "", + "filePresent": "", + "fileReadable": "", + "streamPath": "", "optimizedForStreaming": "Adatfolyam-továbbításra optimalizálva", - "has64bitOffsets": "64 bites eltolások" + "has64bitOffsets": "64 bites eltolások", + "protocol": "", + "mediaType": "", + "sourceKind": "", + "optimizedVersion": "", + "optimizationTarget": "", + "deletedAt": "", + "remoteSource": "", + "infiniteStream": "", + "directPlay": "", + "directStream": "", + "transcoding": "", + "etag": "", + "versionId": "", + "fileId": "", + "defaultAudioTrack": "", + "defaultSubtitleTrack": "", + "subtitlesOff": "", + "flagDefault": "", + "flagForced": "", + "flagSelected": "", + "flagExternal": "", + "flagHearingImpaired": "", + "flagDub": "", + "flagOriginal": "" }, "mediaMenu": { "markAsWatched": "Megjelölés megtekintettként", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 08b928bb..78d46791 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -381,30 +381,118 @@ }, "fileInfo": { "title": "Info sul file", + "overview": "", "video": "Video", "audio": "Audio", + "subtitles": "Sottotitoli", + "images": "", + "dataStreams": "", + "lyrics": "", "file": "File", - "advanced": "Avanzate", + "attachments": "", + "delivery": "", + "versionCounter": "", + "fileCounter": "", + "noStreams": "", + "copyPath": "", + "pathCopied": "", "codec": "Codec", + "codecTag": "", "resolution": "Risoluzione", + "codedResolution": "", "bitrate": "Bitrate", "frameRate": "Frequenza fotogrammi", + "rotation": "", + "comment": "", + "audioDescription": "", + "headerCompression": "", + "sidecarFile": "", + "transportTimestamp": "", + "displayOffset": "", + "previewFailureCode": "", + "previewRetries": "", "aspectRatio": "Proporzioni", + "pixelAspectRatio": "", "profile": "Profilo", + "level": "", "bitDepth": "Profondità in bit", + "pixelFormat": "", "colorSpace": "Spazio colore", "colorRange": "Gamma colori", "colorPrimaries": "Colori primari", + "colorTransfer": "", "chromaSubsampling": "Sottocampionamento cromatico", + "chromaLocation": "", + "scanType": "", + "interlaced": "", + "anamorphic": "", + "referenceFrames": "", + "dynamicRange": "", + "dolbyVision": "", + "dolbyVisionLevel": "", + "dolbyVisionVersion": "", + "dolbyVisionLayers": "", + "baseLayerCompatibility": "", + "avcBitstream": "", + "nalLengthSize": "", + "scalingMatrix": "", + "streamIdentifier": "", + "streamIndex": "", + "streamId": "", + "language": "", + "languageCode": "", + "streamTitle": "", "channels": "Canali", - "subtitles": "Sottotitoli", + "sampleRate": "", + "spatialAudio": "", + "textBased": "", + "subtitleFormat": "", + "provider": "", + "matchScore": "", + "externalDelivery": "", + "sidecarPath": "", + "sourceStream": "", + "temporary": "", + "timeBase": "", "overallBitrate": "Bitrate complessivo", "path": "Percorso", + "fileName": "", "size": "Dimensione", + "totalSize": "", "container": "Contenitore", "duration": "Durata", + "previewThumbnails": "", + "previewIndex": "", + "packetLength": "", + "filePresent": "", + "fileReadable": "", + "streamPath": "", "optimizedForStreaming": "Ottimizzato per lo streaming", - "has64bitOffsets": "Offset a 64 bit" + "has64bitOffsets": "Offset a 64 bit", + "protocol": "", + "mediaType": "", + "sourceKind": "", + "optimizedVersion": "", + "optimizationTarget": "", + "deletedAt": "", + "remoteSource": "", + "infiniteStream": "", + "directPlay": "", + "directStream": "", + "transcoding": "", + "etag": "", + "versionId": "", + "fileId": "", + "defaultAudioTrack": "", + "defaultSubtitleTrack": "", + "subtitlesOff": "", + "flagDefault": "", + "flagForced": "", + "flagSelected": "", + "flagExternal": "", + "flagHearingImpaired": "", + "flagDub": "", + "flagOriginal": "" }, "mediaMenu": { "markAsWatched": "Segna come visto", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 0092e280..93ed8afa 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -381,30 +381,118 @@ }, "fileInfo": { "title": "ファイル情報", + "overview": "", "video": "映像", "audio": "音声", + "subtitles": "字幕", + "images": "", + "dataStreams": "", + "lyrics": "", "file": "ファイル", - "advanced": "詳細", + "attachments": "", + "delivery": "", + "versionCounter": "", + "fileCounter": "", + "noStreams": "", + "copyPath": "", + "pathCopied": "", "codec": "コーデック", + "codecTag": "", "resolution": "解像度", + "codedResolution": "", "bitrate": "ビットレート", "frameRate": "フレームレート", + "rotation": "", + "comment": "", + "audioDescription": "", + "headerCompression": "", + "sidecarFile": "", + "transportTimestamp": "", + "displayOffset": "", + "previewFailureCode": "", + "previewRetries": "", "aspectRatio": "アスペクト比", + "pixelAspectRatio": "", "profile": "プロファイル", + "level": "", "bitDepth": "ビット深度", + "pixelFormat": "", "colorSpace": "色空間", "colorRange": "色範囲", "colorPrimaries": "色原色", + "colorTransfer": "", "chromaSubsampling": "クロマサブサンプリング", + "chromaLocation": "", + "scanType": "", + "interlaced": "", + "anamorphic": "", + "referenceFrames": "", + "dynamicRange": "", + "dolbyVision": "", + "dolbyVisionLevel": "", + "dolbyVisionVersion": "", + "dolbyVisionLayers": "", + "baseLayerCompatibility": "", + "avcBitstream": "", + "nalLengthSize": "", + "scalingMatrix": "", + "streamIdentifier": "", + "streamIndex": "", + "streamId": "", + "language": "", + "languageCode": "", + "streamTitle": "", "channels": "チャンネル", - "subtitles": "字幕", + "sampleRate": "", + "spatialAudio": "", + "textBased": "", + "subtitleFormat": "", + "provider": "", + "matchScore": "", + "externalDelivery": "", + "sidecarPath": "", + "sourceStream": "", + "temporary": "", + "timeBase": "", "overallBitrate": "全体ビットレート", "path": "パス", + "fileName": "", "size": "サイズ", + "totalSize": "", "container": "コンテナ", "duration": "長さ", + "previewThumbnails": "", + "previewIndex": "", + "packetLength": "", + "filePresent": "", + "fileReadable": "", + "streamPath": "", "optimizedForStreaming": "ストリーミング最適化", - "has64bitOffsets": "64ビットオフセット" + "has64bitOffsets": "64ビットオフセット", + "protocol": "", + "mediaType": "", + "sourceKind": "", + "optimizedVersion": "", + "optimizationTarget": "", + "deletedAt": "", + "remoteSource": "", + "infiniteStream": "", + "directPlay": "", + "directStream": "", + "transcoding": "", + "etag": "", + "versionId": "", + "fileId": "", + "defaultAudioTrack": "", + "defaultSubtitleTrack": "", + "subtitlesOff": "", + "flagDefault": "", + "flagForced": "", + "flagSelected": "", + "flagExternal": "", + "flagHearingImpaired": "", + "flagDub": "", + "flagOriginal": "" }, "mediaMenu": { "markAsWatched": "視聴済みにする", diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json index 02366826..684150b3 100644 --- a/lib/i18n/kk.i18n.json +++ b/lib/i18n/kk.i18n.json @@ -381,30 +381,118 @@ }, "fileInfo": { "title": "Файл туралы ақпарат", + "overview": "", "video": "Видео", "audio": "Аудио", + "subtitles": "Субтитрлер", + "images": "", + "dataStreams": "", + "lyrics": "", "file": "Файл", - "advanced": "Кеңейтілген", + "attachments": "", + "delivery": "", + "versionCounter": "", + "fileCounter": "", + "noStreams": "", + "copyPath": "", + "pathCopied": "", "codec": "Кодек", + "codecTag": "", "resolution": "Ажыратымдылық", + "codedResolution": "", "bitrate": "Биттік жылдамдық (Bitrate)", "frameRate": "Кадр жиілігі", + "rotation": "", + "comment": "", + "audioDescription": "", + "headerCompression": "", + "sidecarFile": "", + "transportTimestamp": "", + "displayOffset": "", + "previewFailureCode": "", + "previewRetries": "", "aspectRatio": "Тараптар қатынасы", + "pixelAspectRatio": "", "profile": "Профиль", + "level": "", "bitDepth": "Бит тереңдігі", + "pixelFormat": "", "colorSpace": "Түс кеңістігі", "colorRange": "Түс диапазоны", "colorPrimaries": "Негізгі түстер", + "colorTransfer": "", "chromaSubsampling": "Түстік субдискретизация", + "chromaLocation": "", + "scanType": "", + "interlaced": "", + "anamorphic": "", + "referenceFrames": "", + "dynamicRange": "", + "dolbyVision": "", + "dolbyVisionLevel": "", + "dolbyVisionVersion": "", + "dolbyVisionLayers": "", + "baseLayerCompatibility": "", + "avcBitstream": "", + "nalLengthSize": "", + "scalingMatrix": "", + "streamIdentifier": "", + "streamIndex": "", + "streamId": "", + "language": "", + "languageCode": "", + "streamTitle": "", "channels": "Арналар", - "subtitles": "Субтитрлер", + "sampleRate": "", + "spatialAudio": "", + "textBased": "", + "subtitleFormat": "", + "provider": "", + "matchScore": "", + "externalDelivery": "", + "sidecarPath": "", + "sourceStream": "", + "temporary": "", + "timeBase": "", "overallBitrate": "Жалпы биттік жылдамдық", "path": "Жол", + "fileName": "", "size": "Көлемі", + "totalSize": "", "container": "Контейнер", "duration": "Уақыты", + "previewThumbnails": "", + "previewIndex": "", + "packetLength": "", + "filePresent": "", + "fileReadable": "", + "streamPath": "", "optimizedForStreaming": "Ағынды беру үшін оңтайландырылған", - "has64bitOffsets": "64-биттік ығысулар" + "has64bitOffsets": "64-биттік ығысулар", + "protocol": "", + "mediaType": "", + "sourceKind": "", + "optimizedVersion": "", + "optimizationTarget": "", + "deletedAt": "", + "remoteSource": "", + "infiniteStream": "", + "directPlay": "", + "directStream": "", + "transcoding": "", + "etag": "", + "versionId": "", + "fileId": "", + "defaultAudioTrack": "", + "defaultSubtitleTrack": "", + "subtitlesOff": "", + "flagDefault": "", + "flagForced": "", + "flagSelected": "", + "flagExternal": "", + "flagHearingImpaired": "", + "flagDub": "", + "flagOriginal": "" }, "mediaMenu": { "markAsWatched": "Көрілді деп белгілеу", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index e89bd2ee..e18d131d 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -381,30 +381,118 @@ }, "fileInfo": { "title": "파일 정보", + "overview": "", "video": "비디오", "audio": "오디오", + "subtitles": "자막", + "images": "", + "dataStreams": "", + "lyrics": "", "file": "파일", - "advanced": "고급", + "attachments": "", + "delivery": "", + "versionCounter": "", + "fileCounter": "", + "noStreams": "", + "copyPath": "", + "pathCopied": "", "codec": "코덱", + "codecTag": "", "resolution": "해상도", + "codedResolution": "", "bitrate": "비트레이트", "frameRate": "프레임 속도", + "rotation": "", + "comment": "", + "audioDescription": "", + "headerCompression": "", + "sidecarFile": "", + "transportTimestamp": "", + "displayOffset": "", + "previewFailureCode": "", + "previewRetries": "", "aspectRatio": "종횡비", + "pixelAspectRatio": "", "profile": "프로파일", + "level": "", "bitDepth": "비트 심도", + "pixelFormat": "", "colorSpace": "색 공간", "colorRange": "색 범위", "colorPrimaries": "색 원색", + "colorTransfer": "", "chromaSubsampling": "색차 서브샘플링", + "chromaLocation": "", + "scanType": "", + "interlaced": "", + "anamorphic": "", + "referenceFrames": "", + "dynamicRange": "", + "dolbyVision": "", + "dolbyVisionLevel": "", + "dolbyVisionVersion": "", + "dolbyVisionLayers": "", + "baseLayerCompatibility": "", + "avcBitstream": "", + "nalLengthSize": "", + "scalingMatrix": "", + "streamIdentifier": "", + "streamIndex": "", + "streamId": "", + "language": "", + "languageCode": "", + "streamTitle": "", "channels": "채널", - "subtitles": "자막", + "sampleRate": "", + "spatialAudio": "", + "textBased": "", + "subtitleFormat": "", + "provider": "", + "matchScore": "", + "externalDelivery": "", + "sidecarPath": "", + "sourceStream": "", + "temporary": "", + "timeBase": "", "overallBitrate": "전체 비트레이트", "path": "경로", + "fileName": "", "size": "크기", + "totalSize": "", "container": "컨테이너", "duration": "재생 시간", + "previewThumbnails": "", + "previewIndex": "", + "packetLength": "", + "filePresent": "", + "fileReadable": "", + "streamPath": "", "optimizedForStreaming": "스트리밍 최적화", - "has64bitOffsets": "64비트 오프셋" + "has64bitOffsets": "64비트 오프셋", + "protocol": "", + "mediaType": "", + "sourceKind": "", + "optimizedVersion": "", + "optimizationTarget": "", + "deletedAt": "", + "remoteSource": "", + "infiniteStream": "", + "directPlay": "", + "directStream": "", + "transcoding": "", + "etag": "", + "versionId": "", + "fileId": "", + "defaultAudioTrack": "", + "defaultSubtitleTrack": "", + "subtitlesOff": "", + "flagDefault": "", + "flagForced": "", + "flagSelected": "", + "flagExternal": "", + "flagHearingImpaired": "", + "flagDub": "", + "flagOriginal": "" }, "mediaMenu": { "markAsWatched": "시청 완료로 표시", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 6edef1b8..cd0485b9 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -381,30 +381,118 @@ }, "fileInfo": { "title": "Filinformasjon", + "overview": "", "video": "Video", "audio": "Lyd", + "subtitles": "Undertekster", + "images": "", + "dataStreams": "", + "lyrics": "", "file": "Fil", - "advanced": "Avansert", + "attachments": "", + "delivery": "", + "versionCounter": "", + "fileCounter": "", + "noStreams": "", + "copyPath": "", + "pathCopied": "", "codec": "Kodek", + "codecTag": "", "resolution": "Oppløsning", + "codedResolution": "", "bitrate": "Bitrate", "frameRate": "Bildefrekvens", + "rotation": "", + "comment": "", + "audioDescription": "", + "headerCompression": "", + "sidecarFile": "", + "transportTimestamp": "", + "displayOffset": "", + "previewFailureCode": "", + "previewRetries": "", "aspectRatio": "Sideforhold", + "pixelAspectRatio": "", "profile": "Profil", + "level": "", "bitDepth": "Bitdybde", + "pixelFormat": "", "colorSpace": "Fargerom", "colorRange": "Fargeområde", "colorPrimaries": "Fargeprimærer", + "colorTransfer": "", "chromaSubsampling": "Krominansnedsampling", + "chromaLocation": "", + "scanType": "", + "interlaced": "", + "anamorphic": "", + "referenceFrames": "", + "dynamicRange": "", + "dolbyVision": "", + "dolbyVisionLevel": "", + "dolbyVisionVersion": "", + "dolbyVisionLayers": "", + "baseLayerCompatibility": "", + "avcBitstream": "", + "nalLengthSize": "", + "scalingMatrix": "", + "streamIdentifier": "", + "streamIndex": "", + "streamId": "", + "language": "", + "languageCode": "", + "streamTitle": "", "channels": "Kanaler", - "subtitles": "Undertekster", + "sampleRate": "", + "spatialAudio": "", + "textBased": "", + "subtitleFormat": "", + "provider": "", + "matchScore": "", + "externalDelivery": "", + "sidecarPath": "", + "sourceStream": "", + "temporary": "", + "timeBase": "", "overallBitrate": "Total bitrate", "path": "Sti", + "fileName": "", "size": "Størrelse", + "totalSize": "", "container": "Format", "duration": "Varighet", + "previewThumbnails": "", + "previewIndex": "", + "packetLength": "", + "filePresent": "", + "fileReadable": "", + "streamPath": "", "optimizedForStreaming": "Optimalisert for strømming", - "has64bitOffsets": "64-biters forskyvninger" + "has64bitOffsets": "64-biters forskyvninger", + "protocol": "", + "mediaType": "", + "sourceKind": "", + "optimizedVersion": "", + "optimizationTarget": "", + "deletedAt": "", + "remoteSource": "", + "infiniteStream": "", + "directPlay": "", + "directStream": "", + "transcoding": "", + "etag": "", + "versionId": "", + "fileId": "", + "defaultAudioTrack": "", + "defaultSubtitleTrack": "", + "subtitlesOff": "", + "flagDefault": "", + "flagForced": "", + "flagSelected": "", + "flagExternal": "", + "flagHearingImpaired": "", + "flagDub": "", + "flagOriginal": "" }, "mediaMenu": { "markAsWatched": "Merk som sett", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index f93f64a6..0e54ec1d 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -381,30 +381,118 @@ }, "fileInfo": { "title": "Bestandsinformatie", + "overview": "", "video": "Video", "audio": "Audio", + "subtitles": "Ondertitels", + "images": "", + "dataStreams": "", + "lyrics": "", "file": "Bestand", - "advanced": "Geavanceerd", + "attachments": "", + "delivery": "", + "versionCounter": "", + "fileCounter": "", + "noStreams": "", + "copyPath": "", + "pathCopied": "", "codec": "Codec", + "codecTag": "", "resolution": "Resolutie", + "codedResolution": "", "bitrate": "Bitrate", "frameRate": "Framesnelheid", + "rotation": "", + "comment": "", + "audioDescription": "", + "headerCompression": "", + "sidecarFile": "", + "transportTimestamp": "", + "displayOffset": "", + "previewFailureCode": "", + "previewRetries": "", "aspectRatio": "Beeldverhouding", + "pixelAspectRatio": "", "profile": "Profiel", + "level": "", "bitDepth": "Bitdiepte", + "pixelFormat": "", "colorSpace": "Kleurruimte", "colorRange": "Kleurbereik", "colorPrimaries": "Kleurprimaires", + "colorTransfer": "", "chromaSubsampling": "Chroma-subsampling", + "chromaLocation": "", + "scanType": "", + "interlaced": "", + "anamorphic": "", + "referenceFrames": "", + "dynamicRange": "", + "dolbyVision": "", + "dolbyVisionLevel": "", + "dolbyVisionVersion": "", + "dolbyVisionLayers": "", + "baseLayerCompatibility": "", + "avcBitstream": "", + "nalLengthSize": "", + "scalingMatrix": "", + "streamIdentifier": "", + "streamIndex": "", + "streamId": "", + "language": "", + "languageCode": "", + "streamTitle": "", "channels": "Kanalen", - "subtitles": "Ondertitels", + "sampleRate": "", + "spatialAudio": "", + "textBased": "", + "subtitleFormat": "", + "provider": "", + "matchScore": "", + "externalDelivery": "", + "sidecarPath": "", + "sourceStream": "", + "temporary": "", + "timeBase": "", "overallBitrate": "Totale bitrate", "path": "Pad", + "fileName": "", "size": "Grootte", + "totalSize": "", "container": "Container", "duration": "Duur", + "previewThumbnails": "", + "previewIndex": "", + "packetLength": "", + "filePresent": "", + "fileReadable": "", + "streamPath": "", "optimizedForStreaming": "Geoptimaliseerd voor streaming", - "has64bitOffsets": "64-bits offsets" + "has64bitOffsets": "64-bits offsets", + "protocol": "", + "mediaType": "", + "sourceKind": "", + "optimizedVersion": "", + "optimizationTarget": "", + "deletedAt": "", + "remoteSource": "", + "infiniteStream": "", + "directPlay": "", + "directStream": "", + "transcoding": "", + "etag": "", + "versionId": "", + "fileId": "", + "defaultAudioTrack": "", + "defaultSubtitleTrack": "", + "subtitlesOff": "", + "flagDefault": "", + "flagForced": "", + "flagSelected": "", + "flagExternal": "", + "flagHearingImpaired": "", + "flagDub": "", + "flagOriginal": "" }, "mediaMenu": { "markAsWatched": "Als bekeken markeren", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 0a7230ea..bced8259 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -381,30 +381,118 @@ }, "fileInfo": { "title": "Informacje o pliku", + "overview": "", "video": "Wideo", "audio": "Audio", + "subtitles": "Napisy", + "images": "", + "dataStreams": "", + "lyrics": "", "file": "Plik", - "advanced": "Zaawansowane", + "attachments": "", + "delivery": "", + "versionCounter": "", + "fileCounter": "", + "noStreams": "", + "copyPath": "", + "pathCopied": "", "codec": "Kodek", + "codecTag": "", "resolution": "Rozdzielczość", + "codedResolution": "", "bitrate": "Przepływność", "frameRate": "Klatki na sekundę", + "rotation": "", + "comment": "", + "audioDescription": "", + "headerCompression": "", + "sidecarFile": "", + "transportTimestamp": "", + "displayOffset": "", + "previewFailureCode": "", + "previewRetries": "", "aspectRatio": "Proporcje", + "pixelAspectRatio": "", "profile": "Profil", + "level": "", "bitDepth": "Głębia bitowa", + "pixelFormat": "", "colorSpace": "Przestrzeń kolorów", "colorRange": "Zakres kolorów", "colorPrimaries": "Kolory podstawowe", + "colorTransfer": "", "chromaSubsampling": "Podpróbkowanie chrominancji", + "chromaLocation": "", + "scanType": "", + "interlaced": "", + "anamorphic": "", + "referenceFrames": "", + "dynamicRange": "", + "dolbyVision": "", + "dolbyVisionLevel": "", + "dolbyVisionVersion": "", + "dolbyVisionLayers": "", + "baseLayerCompatibility": "", + "avcBitstream": "", + "nalLengthSize": "", + "scalingMatrix": "", + "streamIdentifier": "", + "streamIndex": "", + "streamId": "", + "language": "", + "languageCode": "", + "streamTitle": "", "channels": "Kanały", - "subtitles": "Napisy", + "sampleRate": "", + "spatialAudio": "", + "textBased": "", + "subtitleFormat": "", + "provider": "", + "matchScore": "", + "externalDelivery": "", + "sidecarPath": "", + "sourceStream": "", + "temporary": "", + "timeBase": "", "overallBitrate": "Całkowita przepływność", "path": "Ścieżka", + "fileName": "", "size": "Rozmiar", + "totalSize": "", "container": "Kontener", "duration": "Czas trwania", + "previewThumbnails": "", + "previewIndex": "", + "packetLength": "", + "filePresent": "", + "fileReadable": "", + "streamPath": "", "optimizedForStreaming": "Zoptymalizowane do strumieniowania", - "has64bitOffsets": "Przesunięcia 64-bitowe" + "has64bitOffsets": "Przesunięcia 64-bitowe", + "protocol": "", + "mediaType": "", + "sourceKind": "", + "optimizedVersion": "", + "optimizationTarget": "", + "deletedAt": "", + "remoteSource": "", + "infiniteStream": "", + "directPlay": "", + "directStream": "", + "transcoding": "", + "etag": "", + "versionId": "", + "fileId": "", + "defaultAudioTrack": "", + "defaultSubtitleTrack": "", + "subtitlesOff": "", + "flagDefault": "", + "flagForced": "", + "flagSelected": "", + "flagExternal": "", + "flagHearingImpaired": "", + "flagDub": "", + "flagOriginal": "" }, "mediaMenu": { "markAsWatched": "Oznacz jako obejrzane", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index d40fb056..b68d26c5 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -381,30 +381,118 @@ }, "fileInfo": { "title": "Informações do arquivo", + "overview": "", "video": "Vídeo", "audio": "Áudio", + "subtitles": "Legendas", + "images": "", + "dataStreams": "", + "lyrics": "", "file": "Arquivo", - "advanced": "Avançado", + "attachments": "", + "delivery": "", + "versionCounter": "", + "fileCounter": "", + "noStreams": "", + "copyPath": "", + "pathCopied": "", "codec": "Codec", + "codecTag": "", "resolution": "Resolução", + "codedResolution": "", "bitrate": "Taxa de bits", "frameRate": "Taxa de Quadros", + "rotation": "", + "comment": "", + "audioDescription": "", + "headerCompression": "", + "sidecarFile": "", + "transportTimestamp": "", + "displayOffset": "", + "previewFailureCode": "", + "previewRetries": "", "aspectRatio": "Proporção", + "pixelAspectRatio": "", "profile": "Perfil", + "level": "", "bitDepth": "Profundidade de bits", + "pixelFormat": "", "colorSpace": "Espaço de Cor", "colorRange": "Faixa de Cor", "colorPrimaries": "Primárias de Cor", + "colorTransfer": "", "chromaSubsampling": "Subamostragem de Croma", + "chromaLocation": "", + "scanType": "", + "interlaced": "", + "anamorphic": "", + "referenceFrames": "", + "dynamicRange": "", + "dolbyVision": "", + "dolbyVisionLevel": "", + "dolbyVisionVersion": "", + "dolbyVisionLayers": "", + "baseLayerCompatibility": "", + "avcBitstream": "", + "nalLengthSize": "", + "scalingMatrix": "", + "streamIdentifier": "", + "streamIndex": "", + "streamId": "", + "language": "", + "languageCode": "", + "streamTitle": "", "channels": "Canais", - "subtitles": "Legendas", + "sampleRate": "", + "spatialAudio": "", + "textBased": "", + "subtitleFormat": "", + "provider": "", + "matchScore": "", + "externalDelivery": "", + "sidecarPath": "", + "sourceStream": "", + "temporary": "", + "timeBase": "", "overallBitrate": "Taxa de bits total", "path": "Caminho", + "fileName": "", "size": "Tamanho", + "totalSize": "", "container": "Contêiner", "duration": "Duração", + "previewThumbnails": "", + "previewIndex": "", + "packetLength": "", + "filePresent": "", + "fileReadable": "", + "streamPath": "", "optimizedForStreaming": "Otimizado para transmissão", - "has64bitOffsets": "Deslocamentos de 64 bits" + "has64bitOffsets": "Deslocamentos de 64 bits", + "protocol": "", + "mediaType": "", + "sourceKind": "", + "optimizedVersion": "", + "optimizationTarget": "", + "deletedAt": "", + "remoteSource": "", + "infiniteStream": "", + "directPlay": "", + "directStream": "", + "transcoding": "", + "etag": "", + "versionId": "", + "fileId": "", + "defaultAudioTrack": "", + "defaultSubtitleTrack": "", + "subtitlesOff": "", + "flagDefault": "", + "flagForced": "", + "flagSelected": "", + "flagExternal": "", + "flagHearingImpaired": "", + "flagDub": "", + "flagOriginal": "" }, "mediaMenu": { "markAsWatched": "Marcar como Assistido", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 0c9187ef..9e4053d8 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -381,30 +381,118 @@ }, "fileInfo": { "title": "Информация о файле", + "overview": "", "video": "Видео", "audio": "Аудио", + "subtitles": "Субтитры", + "images": "", + "dataStreams": "", + "lyrics": "", "file": "Файл", - "advanced": "Дополнительно", + "attachments": "", + "delivery": "", + "versionCounter": "", + "fileCounter": "", + "noStreams": "", + "copyPath": "", + "pathCopied": "", "codec": "Кодек", + "codecTag": "", "resolution": "Разрешение", + "codedResolution": "", "bitrate": "Битрейт", "frameRate": "Частота кадров", + "rotation": "", + "comment": "", + "audioDescription": "", + "headerCompression": "", + "sidecarFile": "", + "transportTimestamp": "", + "displayOffset": "", + "previewFailureCode": "", + "previewRetries": "", "aspectRatio": "Соотношение сторон", + "pixelAspectRatio": "", "profile": "Профиль", + "level": "", "bitDepth": "Глубина цвета", + "pixelFormat": "", "colorSpace": "Цветовое пространство", "colorRange": "Цветовой диапазон", "colorPrimaries": "Основные цвета", + "colorTransfer": "", "chromaSubsampling": "Субдискретизация цветности", + "chromaLocation": "", + "scanType": "", + "interlaced": "", + "anamorphic": "", + "referenceFrames": "", + "dynamicRange": "", + "dolbyVision": "", + "dolbyVisionLevel": "", + "dolbyVisionVersion": "", + "dolbyVisionLayers": "", + "baseLayerCompatibility": "", + "avcBitstream": "", + "nalLengthSize": "", + "scalingMatrix": "", + "streamIdentifier": "", + "streamIndex": "", + "streamId": "", + "language": "", + "languageCode": "", + "streamTitle": "", "channels": "Каналы", - "subtitles": "Субтитры", + "sampleRate": "", + "spatialAudio": "", + "textBased": "", + "subtitleFormat": "", + "provider": "", + "matchScore": "", + "externalDelivery": "", + "sidecarPath": "", + "sourceStream": "", + "temporary": "", + "timeBase": "", "overallBitrate": "Общий битрейт", "path": "Путь", + "fileName": "", "size": "Размер", + "totalSize": "", "container": "Контейнер", "duration": "Длительность", + "previewThumbnails": "", + "previewIndex": "", + "packetLength": "", + "filePresent": "", + "fileReadable": "", + "streamPath": "", "optimizedForStreaming": "Оптимизировано для потоковой передачи", - "has64bitOffsets": "64-битные смещения" + "has64bitOffsets": "64-битные смещения", + "protocol": "", + "mediaType": "", + "sourceKind": "", + "optimizedVersion": "", + "optimizationTarget": "", + "deletedAt": "", + "remoteSource": "", + "infiniteStream": "", + "directPlay": "", + "directStream": "", + "transcoding": "", + "etag": "", + "versionId": "", + "fileId": "", + "defaultAudioTrack": "", + "defaultSubtitleTrack": "", + "subtitlesOff": "", + "flagDefault": "", + "flagForced": "", + "flagSelected": "", + "flagExternal": "", + "flagHearingImpaired": "", + "flagDub": "", + "flagOriginal": "" }, "mediaMenu": { "markAsWatched": "Отметить как просмотренное", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index e1d56281..703aec31 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 22 -/// Strings: 32683 (1485 per locale) +/// Strings: 32750 (1488 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_az.g.dart b/lib/i18n/strings_az.g.dart index fff2783d..a72359ad 100644 --- a/lib/i18n/strings_az.g.dart +++ b/lib/i18n/strings_az.g.dart @@ -510,8 +510,8 @@ class _Translations$fileInfo$az extends Translations$fileInfo$en { @override String get title => 'Fayl məlumatı'; @override String get video => 'Video'; @override String get audio => 'Səs'; + @override String get subtitles => 'Altyazılar'; @override String get file => 'Fayl'; - @override String get advanced => 'Təkmilləşdirilmiş'; @override String get codec => 'Kodek'; @override String get resolution => 'Ayırdetmə'; @override String get bitrate => 'Bit sürəti (Bitrate)'; @@ -524,7 +524,6 @@ class _Translations$fileInfo$az extends Translations$fileInfo$en { @override String get colorPrimaries => 'Əsas rənglər'; @override String get chromaSubsampling => 'Rəng alt-diskretləşdirməsi'; @override String get channels => 'Kanallar'; - @override String get subtitles => 'Altyazılar'; @override String get overallBitrate => 'Ümumi bit sürəti'; @override String get path => 'Yol'; @override String get size => 'Həcm'; @@ -2574,8 +2573,8 @@ extension on TranslationsAz { 'fileInfo.title' => 'Fayl məlumatı', 'fileInfo.video' => 'Video', 'fileInfo.audio' => 'Səs', + 'fileInfo.subtitles' => 'Altyazılar', 'fileInfo.file' => 'Fayl', - 'fileInfo.advanced' => 'Təkmilləşdirilmiş', 'fileInfo.codec' => 'Kodek', 'fileInfo.resolution' => 'Ayırdetmə', 'fileInfo.bitrate' => 'Bit sürəti (Bitrate)', @@ -2588,7 +2587,6 @@ extension on TranslationsAz { 'fileInfo.colorPrimaries' => 'Əsas rənglər', 'fileInfo.chromaSubsampling' => 'Rəng alt-diskretləşdirməsi', 'fileInfo.channels' => 'Kanallar', - 'fileInfo.subtitles' => 'Altyazılar', 'fileInfo.overallBitrate' => 'Ümumi bit sürəti', 'fileInfo.path' => 'Yol', 'fileInfo.size' => 'Həcm', @@ -2721,9 +2719,9 @@ extension on TranslationsAz { 'videoControls.queue' => 'Növbə', 'videoControls.noQueueItems' => 'Növbədə element yoxdur', 'videoControls.searchSubtitles' => 'Altyazı axtar', + 'videoControls.language' => 'Dil', _ => null, } ?? switch (path) { - 'videoControls.language' => 'Dil', 'videoControls.noSubtitlesFound' => 'Altyazı tapılmadı', 'videoControls.subtitleDownloaded' => 'Altyazı yükləndi', 'videoControls.subtitleDownloadedNotApplied' => 'Altyazı yükləndi, lakin seçilə bilmədi', @@ -3235,9 +3233,9 @@ extension on TranslationsAz { 'watchTogether.pasteFromClipboard' => 'Buferdən yapışdır', 'watchTogether.pleaseEnterCode' => 'Lütfən seans kodunu daxil edin', 'watchTogether.codeMustBe5Chars' => 'Seans kodu 5 simvol olmalıdır', + 'watchTogether.joinInstructions' => 'Qoşulmaq üçün təşkilatçının seans kodunu daxil edin.', _ => null, } ?? switch (path) { - 'watchTogether.joinInstructions' => 'Qoşulmaq üçün təşkilatçının seans kodunu daxil edin.', 'watchTogether.failedToCreate' => 'Seans yaradıla bilmədi', 'watchTogether.failedToJoin' => 'Seansa qoşuluna bilmədi', 'watchTogether.sessionCodeCopied' => 'Seans kodu buferə kopyalandı', diff --git a/lib/i18n/strings_bg.g.dart b/lib/i18n/strings_bg.g.dart index 8cf61c4d..fde57497 100644 --- a/lib/i18n/strings_bg.g.dart +++ b/lib/i18n/strings_bg.g.dart @@ -507,8 +507,8 @@ class _Translations$fileInfo$bg extends Translations$fileInfo$en { @override String get title => 'Информация за файла'; @override String get video => 'Видео'; @override String get audio => 'Аудио'; + @override String get subtitles => 'Субтитри'; @override String get file => 'Файл'; - @override String get advanced => 'Разширени'; @override String get codec => 'Кодек'; @override String get resolution => 'Резолюция'; @override String get bitrate => 'Битрейт'; @@ -521,7 +521,6 @@ class _Translations$fileInfo$bg extends Translations$fileInfo$en { @override String get colorPrimaries => 'Основни цветове'; @override String get chromaSubsampling => 'Цветова субдискретизация'; @override String get channels => 'Канали'; - @override String get subtitles => 'Субтитри'; @override String get overallBitrate => 'Общ битрейт'; @override String get path => 'Път'; @override String get size => 'Размер'; @@ -2560,8 +2559,8 @@ extension on TranslationsBg { 'fileInfo.title' => 'Информация за файла', 'fileInfo.video' => 'Видео', 'fileInfo.audio' => 'Аудио', + 'fileInfo.subtitles' => 'Субтитри', 'fileInfo.file' => 'Файл', - 'fileInfo.advanced' => 'Разширени', 'fileInfo.codec' => 'Кодек', 'fileInfo.resolution' => 'Резолюция', 'fileInfo.bitrate' => 'Битрейт', @@ -2574,7 +2573,6 @@ extension on TranslationsBg { 'fileInfo.colorPrimaries' => 'Основни цветове', 'fileInfo.chromaSubsampling' => 'Цветова субдискретизация', 'fileInfo.channels' => 'Канали', - 'fileInfo.subtitles' => 'Субтитри', 'fileInfo.overallBitrate' => 'Общ битрейт', 'fileInfo.path' => 'Път', 'fileInfo.size' => 'Размер', @@ -2710,9 +2708,9 @@ extension on TranslationsBg { 'videoControls.language' => 'Език', 'videoControls.noSubtitlesFound' => 'Не са намерени субтитри', 'videoControls.subtitleDownloaded' => 'Субтитърът е изтеглен', + 'videoControls.subtitleDownloadedNotApplied' => 'Субтитрите са изтеглени, но не можаха да бъдат избрани', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloadedNotApplied' => 'Субтитрите са изтеглени, но не можаха да бъдат избрани', 'videoControls.subtitleDownloadFailed' => 'Неуспешно изтегляне на субтитър', 'videoControls.searchLanguages' => 'Търсене на езици...', 'messages.markedAsWatched' => 'Маркирано като гледано', @@ -3224,9 +3222,9 @@ extension on TranslationsBg { 'watchTogether.failedToJoin' => 'Неуспешно присъединяване към сесия', 'watchTogether.sessionCodeCopied' => 'Кодът на сесията е копиран в клипборда', 'watchTogether.relayUnreachable' => 'Релейният сървър е недостъпен. Възможно е интернет доставчикът да блокира гледането заедно.', + 'watchTogether.reconnectingToHost' => 'Повторно свързване с организатора...', _ => null, } ?? switch (path) { - 'watchTogether.reconnectingToHost' => 'Повторно свързване с организатора...', 'watchTogether.currentPlayback' => 'Текущо възпроизвеждане', 'watchTogether.joinCurrentPlayback' => 'Присъедини се към текущото възпроизвеждане', 'watchTogether.joinCurrentPlaybackDescription' => 'Върнете се към това, което организаторът гледа в момента', diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index e0a3cb61..227076f2 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -507,8 +507,8 @@ class _Translations$fileInfo$da extends Translations$fileInfo$en { @override String get title => 'Filinfo'; @override String get video => 'Video'; @override String get audio => 'Lyd'; + @override String get subtitles => 'Undertekster'; @override String get file => 'Fil'; - @override String get advanced => 'Avanceret'; @override String get codec => 'Codec'; @override String get resolution => 'Opløsning'; @override String get bitrate => 'Bitrate'; @@ -521,7 +521,6 @@ class _Translations$fileInfo$da extends Translations$fileInfo$en { @override String get colorPrimaries => 'Farveprimærer'; @override String get chromaSubsampling => 'Chroma-subsampling'; @override String get channels => 'Kanaler'; - @override String get subtitles => 'Undertekster'; @override String get overallBitrate => 'Samlet bitrate'; @override String get path => 'Sti'; @override String get size => 'Størrelse'; @@ -2560,8 +2559,8 @@ extension on TranslationsDa { 'fileInfo.title' => 'Filinfo', 'fileInfo.video' => 'Video', 'fileInfo.audio' => 'Lyd', + 'fileInfo.subtitles' => 'Undertekster', 'fileInfo.file' => 'Fil', - 'fileInfo.advanced' => 'Avanceret', 'fileInfo.codec' => 'Codec', 'fileInfo.resolution' => 'Opløsning', 'fileInfo.bitrate' => 'Bitrate', @@ -2574,7 +2573,6 @@ extension on TranslationsDa { 'fileInfo.colorPrimaries' => 'Farveprimærer', 'fileInfo.chromaSubsampling' => 'Chroma-subsampling', 'fileInfo.channels' => 'Kanaler', - 'fileInfo.subtitles' => 'Undertekster', 'fileInfo.overallBitrate' => 'Samlet bitrate', 'fileInfo.path' => 'Sti', 'fileInfo.size' => 'Størrelse', @@ -2710,9 +2708,9 @@ extension on TranslationsDa { 'videoControls.language' => 'Sprog', 'videoControls.noSubtitlesFound' => 'Ingen undertekster fundet', 'videoControls.subtitleDownloaded' => 'Undertekst downloadet', + 'videoControls.subtitleDownloadedNotApplied' => 'Underteksten blev downloadet, men kunne ikke vælges', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloadedNotApplied' => 'Underteksten blev downloadet, men kunne ikke vælges', 'videoControls.subtitleDownloadFailed' => 'Kunne ikke downloade undertekst', 'videoControls.searchLanguages' => 'Søg sprog...', 'messages.markedAsWatched' => 'Markeret som set', @@ -3224,9 +3222,9 @@ extension on TranslationsDa { 'watchTogether.failedToJoin' => 'Kunne ikke deltage i session', 'watchTogether.sessionCodeCopied' => 'Sessionskode kopieret til udklipsholder', 'watchTogether.relayUnreachable' => 'Relayserveren kan ikke nås. Blokering hos internetudbyderen kan forhindre Se sammen.', + 'watchTogether.reconnectingToHost' => 'Genopretter forbindelse til vært...', _ => null, } ?? switch (path) { - 'watchTogether.reconnectingToHost' => 'Genopretter forbindelse til vært...', 'watchTogether.currentPlayback' => 'Nuværende afspilning', 'watchTogether.joinCurrentPlayback' => 'Deltag i nuværende afspilning', 'watchTogether.joinCurrentPlaybackDescription' => 'Hop tilbage til det værten ser nu', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 186e622e..f0d5f646 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -507,8 +507,8 @@ class _Translations$fileInfo$de extends Translations$fileInfo$en { @override String get title => 'Dateiinformationen'; @override String get video => 'Video'; @override String get audio => 'Audio'; + @override String get subtitles => 'Untertitel'; @override String get file => 'Datei'; - @override String get advanced => 'Erweitert'; @override String get codec => 'Codec'; @override String get resolution => 'Auflösung'; @override String get bitrate => 'Bitrate'; @@ -521,7 +521,6 @@ class _Translations$fileInfo$de extends Translations$fileInfo$en { @override String get colorPrimaries => 'Primärfarben'; @override String get chromaSubsampling => 'Chroma-Subsampling'; @override String get channels => 'Kanäle'; - @override String get subtitles => 'Untertitel'; @override String get overallBitrate => 'Gesamtbitrate'; @override String get path => 'Pfad'; @override String get size => 'Größe'; @@ -2560,8 +2559,8 @@ extension on TranslationsDe { 'fileInfo.title' => 'Dateiinformationen', 'fileInfo.video' => 'Video', 'fileInfo.audio' => 'Audio', + 'fileInfo.subtitles' => 'Untertitel', 'fileInfo.file' => 'Datei', - 'fileInfo.advanced' => 'Erweitert', 'fileInfo.codec' => 'Codec', 'fileInfo.resolution' => 'Auflösung', 'fileInfo.bitrate' => 'Bitrate', @@ -2574,7 +2573,6 @@ extension on TranslationsDe { 'fileInfo.colorPrimaries' => 'Primärfarben', 'fileInfo.chromaSubsampling' => 'Chroma-Subsampling', 'fileInfo.channels' => 'Kanäle', - 'fileInfo.subtitles' => 'Untertitel', 'fileInfo.overallBitrate' => 'Gesamtbitrate', 'fileInfo.path' => 'Pfad', 'fileInfo.size' => 'Größe', @@ -2710,9 +2708,9 @@ extension on TranslationsDe { 'videoControls.language' => 'Sprache', 'videoControls.noSubtitlesFound' => 'Keine Untertitel gefunden', 'videoControls.subtitleDownloaded' => 'Untertitel heruntergeladen', + 'videoControls.subtitleDownloadedNotApplied' => 'Der Untertitel wurde heruntergeladen, konnte aber nicht ausgewählt werden', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloadedNotApplied' => 'Der Untertitel wurde heruntergeladen, konnte aber nicht ausgewählt werden', 'videoControls.subtitleDownloadFailed' => 'Untertitel konnte nicht heruntergeladen werden', 'videoControls.searchLanguages' => 'Sprachen suchen...', 'messages.markedAsWatched' => 'Als gesehen markiert', @@ -3224,9 +3222,9 @@ extension on TranslationsDe { 'watchTogether.failedToJoin' => 'Beitritt zur Sitzung fehlgeschlagen', 'watchTogether.sessionCodeCopied' => 'Sitzungscode in Zwischenablage kopiert', 'watchTogether.relayUnreachable' => 'Relay-Server nicht erreichbar. Eine Sperre durch den Internetanbieter kann gemeinsames Schauen verhindern.', + 'watchTogether.reconnectingToHost' => 'Verbindung zum Host wird wiederhergestellt …', _ => null, } ?? switch (path) { - 'watchTogether.reconnectingToHost' => 'Verbindung zum Host wird wiederhergestellt …', 'watchTogether.currentPlayback' => 'Aktuelle Wiedergabe', 'watchTogether.joinCurrentPlayback' => 'Aktueller Wiedergabe beitreten', 'watchTogether.joinCurrentPlaybackDescription' => 'Zu dem Inhalt wechseln, den der Host gerade ansieht', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 4a1cd731..0a0b6b62 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -1187,39 +1187,114 @@ class Translations$fileInfo$en { /// en: 'File Info' String get title => 'File Info'; + /// en: 'Overview' + String get overview => 'Overview'; + /// en: 'Video' String get video => 'Video'; /// en: 'Audio' String get audio => 'Audio'; + /// en: 'Subtitles' + String get subtitles => 'Subtitles'; + + /// en: 'Embedded Images' + String get images => 'Embedded Images'; + + /// en: 'Data Streams' + String get dataStreams => 'Data Streams'; + + /// en: 'Lyrics' + String get lyrics => 'Lyrics'; + /// en: 'File' String get file => 'File'; - /// en: 'Advanced' - String get advanced => 'Advanced'; + /// en: 'Attachments' + String get attachments => 'Attachments'; + + /// en: 'Delivery' + String get delivery => 'Delivery'; + + /// en: 'Version ${index} of ${count}' + String versionCounter({required Object index, required Object count}) => 'Version ${index} of ${count}'; + + /// en: 'File ${index} of ${count}' + String fileCounter({required Object index, required Object count}) => 'File ${index} of ${count}'; + + /// en: 'The server reported no streams for this file.' + String get noStreams => 'The server reported no streams for this file.'; + + /// en: 'Copy path' + String get copyPath => 'Copy path'; + + /// en: 'File path copied' + String get pathCopied => 'File path copied'; /// en: 'Codec' String get codec => 'Codec'; + /// en: 'Codec Tag' + String get codecTag => 'Codec Tag'; + /// en: 'Resolution' String get resolution => 'Resolution'; + /// en: 'Coded Resolution' + String get codedResolution => 'Coded Resolution'; + /// en: 'Bitrate' String get bitrate => 'Bitrate'; /// en: 'Frame Rate' String get frameRate => 'Frame Rate'; + /// en: 'Rotation' + String get rotation => 'Rotation'; + + /// en: 'Comment' + String get comment => 'Comment'; + + /// en: 'Audio Description' + String get audioDescription => 'Audio Description'; + + /// en: 'Header Compression' + String get headerCompression => 'Header Compression'; + + /// en: 'Sidecar File' + String get sidecarFile => 'Sidecar File'; + + /// en: 'Transport Timestamp' + String get transportTimestamp => 'Transport Timestamp'; + + /// en: 'Display Offset' + String get displayOffset => 'Display Offset'; + + /// en: 'Preview Failure Code' + String get previewFailureCode => 'Preview Failure Code'; + + /// en: 'Preview Retries' + String get previewRetries => 'Preview Retries'; + /// en: 'Aspect Ratio' String get aspectRatio => 'Aspect Ratio'; + /// en: 'Pixel Aspect Ratio' + String get pixelAspectRatio => 'Pixel Aspect Ratio'; + /// en: 'Profile' String get profile => 'Profile'; + /// en: 'Level' + String get level => 'Level'; + /// en: 'Bit Depth' String get bitDepth => 'Bit Depth'; + /// en: 'Pixel Format' + String get pixelFormat => 'Pixel Format'; + /// en: 'Color Space' String get colorSpace => 'Color Space'; @@ -1229,14 +1304,107 @@ class Translations$fileInfo$en { /// en: 'Color Primaries' String get colorPrimaries => 'Color Primaries'; + /// en: 'Color Transfer' + String get colorTransfer => 'Color Transfer'; + /// en: 'Chroma Subsampling' String get chromaSubsampling => 'Chroma Subsampling'; + /// en: 'Chroma Location' + String get chromaLocation => 'Chroma Location'; + + /// en: 'Scan Type' + String get scanType => 'Scan Type'; + + /// en: 'Interlaced' + String get interlaced => 'Interlaced'; + + /// en: 'Anamorphic' + String get anamorphic => 'Anamorphic'; + + /// en: 'Reference Frames' + String get referenceFrames => 'Reference Frames'; + + /// en: 'Dynamic Range' + String get dynamicRange => 'Dynamic Range'; + + /// en: 'Dolby Vision' + String get dolbyVision => 'Dolby Vision'; + + /// en: 'Dolby Vision Level' + String get dolbyVisionLevel => 'Dolby Vision Level'; + + /// en: 'Dolby Vision Version' + String get dolbyVisionVersion => 'Dolby Vision Version'; + + /// en: 'Dolby Vision Layers' + String get dolbyVisionLayers => 'Dolby Vision Layers'; + + /// en: 'Base Layer Compatibility' + String get baseLayerCompatibility => 'Base Layer Compatibility'; + + /// en: 'AVC Bitstream' + String get avcBitstream => 'AVC Bitstream'; + + /// en: 'NAL Length Size' + String get nalLengthSize => 'NAL Length Size'; + + /// en: 'Custom Scaling Matrix' + String get scalingMatrix => 'Custom Scaling Matrix'; + + /// en: 'Stream Identifier' + String get streamIdentifier => 'Stream Identifier'; + + /// en: 'Stream Index' + String get streamIndex => 'Stream Index'; + + /// en: 'Stream ID' + String get streamId => 'Stream ID'; + + /// en: 'Language' + String get language => 'Language'; + + /// en: 'Language Code' + String get languageCode => 'Language Code'; + + /// en: 'Track Title' + String get streamTitle => 'Track Title'; + /// en: 'Channels' String get channels => 'Channels'; - /// en: 'Subtitles' - String get subtitles => 'Subtitles'; + /// en: 'Sample Rate' + String get sampleRate => 'Sample Rate'; + + /// en: 'Spatial Audio' + String get spatialAudio => 'Spatial Audio'; + + /// en: 'Text Based' + String get textBased => 'Text Based'; + + /// en: 'Sidecar Format' + String get subtitleFormat => 'Sidecar Format'; + + /// en: 'Provider' + String get provider => 'Provider'; + + /// en: 'Match Score' + String get matchScore => 'Match Score'; + + /// en: 'Can Be Served Separately' + String get externalDelivery => 'Can Be Served Separately'; + + /// en: 'Sidecar Path' + String get sidecarPath => 'Sidecar Path'; + + /// en: 'Copied From' + String get sourceStream => 'Copied From'; + + /// en: 'Temporary' + String get temporary => 'Temporary'; + + /// en: 'Time Base' + String get timeBase => 'Time Base'; /// en: 'Overall Bitrate' String get overallBitrate => 'Overall Bitrate'; @@ -1244,20 +1412,116 @@ class Translations$fileInfo$en { /// en: 'Path' String get path => 'Path'; + /// en: 'File Name' + String get fileName => 'File Name'; + /// en: 'Size' String get size => 'Size'; + /// en: 'Total Size' + String get totalSize => 'Total Size'; + /// en: 'Container' String get container => 'Container'; /// en: 'Duration' String get duration => 'Duration'; + /// en: 'Preview Thumbnails' + String get previewThumbnails => 'Preview Thumbnails'; + + /// en: 'Preview Index' + String get previewIndex => 'Preview Index'; + + /// en: 'Packet Length' + String get packetLength => 'Packet Length'; + + /// en: 'File Present' + String get filePresent => 'File Present'; + + /// en: 'Readable by Server' + String get fileReadable => 'Readable by Server'; + + /// en: 'Stream Path' + String get streamPath => 'Stream Path'; + /// en: 'Optimized for Streaming' String get optimizedForStreaming => 'Optimized for Streaming'; /// en: '64-bit Offsets' String get has64bitOffsets => '64-bit Offsets'; + + /// en: 'Protocol' + String get protocol => 'Protocol'; + + /// en: 'Media Type' + String get mediaType => 'Media Type'; + + /// en: 'Source Kind' + String get sourceKind => 'Source Kind'; + + /// en: 'Optimized Version' + String get optimizedVersion => 'Optimized Version'; + + /// en: 'Optimization Target' + String get optimizationTarget => 'Optimization Target'; + + /// en: 'Deleted' + String get deletedAt => 'Deleted'; + + /// en: 'Remote Source' + String get remoteSource => 'Remote Source'; + + /// en: 'Infinite Stream' + String get infiniteStream => 'Infinite Stream'; + + /// en: 'Direct Play' + String get directPlay => 'Direct Play'; + + /// en: 'Direct Stream' + String get directStream => 'Direct Stream'; + + /// en: 'Transcoding' + String get transcoding => 'Transcoding'; + + /// en: 'ETag' + String get etag => 'ETag'; + + /// en: 'Version ID' + String get versionId => 'Version ID'; + + /// en: 'File ID' + String get fileId => 'File ID'; + + /// en: 'Default Audio Track' + String get defaultAudioTrack => 'Default Audio Track'; + + /// en: 'Default Subtitle Track' + String get defaultSubtitleTrack => 'Default Subtitle Track'; + + /// en: 'Off' + String get subtitlesOff => 'Off'; + + /// en: 'Default' + String get flagDefault => 'Default'; + + /// en: 'Forced' + String get flagForced => 'Forced'; + + /// en: 'Selected' + String get flagSelected => 'Selected'; + + /// en: 'External' + String get flagExternal => 'External'; + + /// en: 'Hearing impaired' + String get flagHearingImpaired => 'Hearing impaired'; + + /// en: 'Dub' + String get flagDub => 'Dub'; + + /// en: 'Original' + String get flagOriginal => 'Original'; } // Path: mediaMenu @@ -5568,30 +5832,118 @@ extension on Translations { 'hotkeys.actions.skipMarker' => 'Skip Intro/Credits', 'hotkeys.actions.screenshot' => 'Take Screenshot', 'fileInfo.title' => 'File Info', + 'fileInfo.overview' => 'Overview', 'fileInfo.video' => 'Video', 'fileInfo.audio' => 'Audio', + 'fileInfo.subtitles' => 'Subtitles', + 'fileInfo.images' => 'Embedded Images', + 'fileInfo.dataStreams' => 'Data Streams', + 'fileInfo.lyrics' => 'Lyrics', 'fileInfo.file' => 'File', - 'fileInfo.advanced' => 'Advanced', + 'fileInfo.attachments' => 'Attachments', + 'fileInfo.delivery' => 'Delivery', + 'fileInfo.versionCounter' => ({required Object index, required Object count}) => 'Version ${index} of ${count}', + 'fileInfo.fileCounter' => ({required Object index, required Object count}) => 'File ${index} of ${count}', + 'fileInfo.noStreams' => 'The server reported no streams for this file.', + 'fileInfo.copyPath' => 'Copy path', + 'fileInfo.pathCopied' => 'File path copied', 'fileInfo.codec' => 'Codec', + 'fileInfo.codecTag' => 'Codec Tag', 'fileInfo.resolution' => 'Resolution', + 'fileInfo.codedResolution' => 'Coded Resolution', 'fileInfo.bitrate' => 'Bitrate', 'fileInfo.frameRate' => 'Frame Rate', + 'fileInfo.rotation' => 'Rotation', + 'fileInfo.comment' => 'Comment', + 'fileInfo.audioDescription' => 'Audio Description', + 'fileInfo.headerCompression' => 'Header Compression', + 'fileInfo.sidecarFile' => 'Sidecar File', + 'fileInfo.transportTimestamp' => 'Transport Timestamp', + 'fileInfo.displayOffset' => 'Display Offset', + 'fileInfo.previewFailureCode' => 'Preview Failure Code', + 'fileInfo.previewRetries' => 'Preview Retries', 'fileInfo.aspectRatio' => 'Aspect Ratio', + 'fileInfo.pixelAspectRatio' => 'Pixel Aspect Ratio', 'fileInfo.profile' => 'Profile', + 'fileInfo.level' => 'Level', 'fileInfo.bitDepth' => 'Bit Depth', + 'fileInfo.pixelFormat' => 'Pixel Format', 'fileInfo.colorSpace' => 'Color Space', 'fileInfo.colorRange' => 'Color Range', 'fileInfo.colorPrimaries' => 'Color Primaries', + 'fileInfo.colorTransfer' => 'Color Transfer', 'fileInfo.chromaSubsampling' => 'Chroma Subsampling', + 'fileInfo.chromaLocation' => 'Chroma Location', + 'fileInfo.scanType' => 'Scan Type', + 'fileInfo.interlaced' => 'Interlaced', + 'fileInfo.anamorphic' => 'Anamorphic', + 'fileInfo.referenceFrames' => 'Reference Frames', + 'fileInfo.dynamicRange' => 'Dynamic Range', + 'fileInfo.dolbyVision' => 'Dolby Vision', + 'fileInfo.dolbyVisionLevel' => 'Dolby Vision Level', + 'fileInfo.dolbyVisionVersion' => 'Dolby Vision Version', + 'fileInfo.dolbyVisionLayers' => 'Dolby Vision Layers', + 'fileInfo.baseLayerCompatibility' => 'Base Layer Compatibility', + 'fileInfo.avcBitstream' => 'AVC Bitstream', + 'fileInfo.nalLengthSize' => 'NAL Length Size', + 'fileInfo.scalingMatrix' => 'Custom Scaling Matrix', + 'fileInfo.streamIdentifier' => 'Stream Identifier', + 'fileInfo.streamIndex' => 'Stream Index', + 'fileInfo.streamId' => 'Stream ID', + 'fileInfo.language' => 'Language', + 'fileInfo.languageCode' => 'Language Code', + 'fileInfo.streamTitle' => 'Track Title', 'fileInfo.channels' => 'Channels', - 'fileInfo.subtitles' => 'Subtitles', + 'fileInfo.sampleRate' => 'Sample Rate', + 'fileInfo.spatialAudio' => 'Spatial Audio', + 'fileInfo.textBased' => 'Text Based', + 'fileInfo.subtitleFormat' => 'Sidecar Format', + 'fileInfo.provider' => 'Provider', + 'fileInfo.matchScore' => 'Match Score', + 'fileInfo.externalDelivery' => 'Can Be Served Separately', + 'fileInfo.sidecarPath' => 'Sidecar Path', + 'fileInfo.sourceStream' => 'Copied From', + 'fileInfo.temporary' => 'Temporary', + 'fileInfo.timeBase' => 'Time Base', 'fileInfo.overallBitrate' => 'Overall Bitrate', 'fileInfo.path' => 'Path', + 'fileInfo.fileName' => 'File Name', 'fileInfo.size' => 'Size', + 'fileInfo.totalSize' => 'Total Size', 'fileInfo.container' => 'Container', 'fileInfo.duration' => 'Duration', + 'fileInfo.previewThumbnails' => 'Preview Thumbnails', + 'fileInfo.previewIndex' => 'Preview Index', + 'fileInfo.packetLength' => 'Packet Length', + 'fileInfo.filePresent' => 'File Present', + 'fileInfo.fileReadable' => 'Readable by Server', + 'fileInfo.streamPath' => 'Stream Path', 'fileInfo.optimizedForStreaming' => 'Optimized for Streaming', 'fileInfo.has64bitOffsets' => '64-bit Offsets', + 'fileInfo.protocol' => 'Protocol', + 'fileInfo.mediaType' => 'Media Type', + 'fileInfo.sourceKind' => 'Source Kind', + 'fileInfo.optimizedVersion' => 'Optimized Version', + 'fileInfo.optimizationTarget' => 'Optimization Target', + 'fileInfo.deletedAt' => 'Deleted', + 'fileInfo.remoteSource' => 'Remote Source', + 'fileInfo.infiniteStream' => 'Infinite Stream', + 'fileInfo.directPlay' => 'Direct Play', + 'fileInfo.directStream' => 'Direct Stream', + 'fileInfo.transcoding' => 'Transcoding', + 'fileInfo.etag' => 'ETag', + 'fileInfo.versionId' => 'Version ID', + 'fileInfo.fileId' => 'File ID', + 'fileInfo.defaultAudioTrack' => 'Default Audio Track', + 'fileInfo.defaultSubtitleTrack' => 'Default Subtitle Track', + 'fileInfo.subtitlesOff' => 'Off', + 'fileInfo.flagDefault' => 'Default', + 'fileInfo.flagForced' => 'Forced', + 'fileInfo.flagSelected' => 'Selected', + 'fileInfo.flagExternal' => 'External', + 'fileInfo.flagHearingImpaired' => 'Hearing impaired', + 'fileInfo.flagDub' => 'Dub', + 'fileInfo.flagOriginal' => 'Original', 'mediaMenu.markAsWatched' => 'Mark as Watched', 'mediaMenu.markAsUnwatched' => 'Mark as Unwatched', 'mediaMenu.removeFromContinueWatching' => 'Remove from Continue Watching', @@ -5629,6 +5981,8 @@ extension on Translations { 'accessibility.increaseValue' => ({required Object label}) => 'Increase ${label}', 'accessibility.hue' => 'Hue', 'accessibility.saturation' => 'Saturation', + _ => null, + } ?? switch (path) { 'accessibility.brightness' => 'Brightness', 'accessibility.hexColor' => 'Hex color', 'accessibility.expandText' => 'Expand text', @@ -5717,8 +6071,6 @@ extension on Translations { 'videoControls.chapters' => 'Chapters', 'videoControls.noChaptersAvailable' => 'No chapters available', 'videoControls.queue' => 'Queue', - _ => null, - } ?? switch (path) { 'videoControls.noQueueItems' => 'No items in queue', 'videoControls.searchSubtitles' => 'Search Subtitles', 'videoControls.language' => 'Language', @@ -6143,6 +6495,8 @@ extension on Translations { 'collections.removeFromCollectionConfirm' => ({required Object title}) => 'Remove "${title}" from this collection?', 'collections.removedFromCollection' => 'Removed from collection', 'collections.removeFromCollectionFailed' => 'Failed to remove from collection', + _ => null, + } ?? switch (path) { 'collections.removeFromCollectionError' => ({required Object error}) => 'Error removing from collection: ${error}', 'collections.searchCollections' => 'Search collections...', 'playlists.title' => 'Playlists', @@ -6231,8 +6585,6 @@ extension on Translations { 'watchTogether.joinWatchSession' => 'Join Watch Session', 'watchTogether.enterCodeHint' => 'Enter 5-character code', 'watchTogether.pasteFromClipboard' => 'Paste from clipboard', - _ => null, - } ?? switch (path) { 'watchTogether.pleaseEnterCode' => 'Please enter a session code', 'watchTogether.codeMustBe5Chars' => 'Session code must be 5 characters', 'watchTogether.joinInstructions' => 'Enter the host\'s session code to join.', @@ -6657,6 +7009,8 @@ extension on Translations { 'services.deviceCode.copyCode' => 'Copy activation code', 'services.deviceCode.waitingForAuthorization' => 'Waiting for authorization…', 'services.deviceCode.codeCopied' => 'Code copied', + _ => null, + } ?? switch (path) { 'services.oauthProxy.title' => ({required Object service}) => 'Sign in to ${service}', 'services.oauthProxy.body' => 'Scan this QR code or open the URL on any device.', 'services.oauthProxy.openToSignIn' => ({required Object service}) => 'Open ${service} to sign in', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 4a3638b9..a3ec0a68 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -507,8 +507,8 @@ class _Translations$fileInfo$es extends Translations$fileInfo$en { @override String get title => 'Información del archivo'; @override String get video => 'Video'; @override String get audio => 'Audio'; + @override String get subtitles => 'Subtítulos'; @override String get file => 'Archivo'; - @override String get advanced => 'Avanzado'; @override String get codec => 'Códec'; @override String get resolution => 'Resolución'; @override String get bitrate => 'Tasa de bits'; @@ -521,7 +521,6 @@ class _Translations$fileInfo$es extends Translations$fileInfo$en { @override String get colorPrimaries => 'Primarias de color'; @override String get chromaSubsampling => 'Submuestreo de croma'; @override String get channels => 'Canales'; - @override String get subtitles => 'Subtítulos'; @override String get overallBitrate => 'Tasa de bits total'; @override String get path => 'Ruta'; @override String get size => 'Tamaño'; @@ -2560,8 +2559,8 @@ extension on TranslationsEs { 'fileInfo.title' => 'Información del archivo', 'fileInfo.video' => 'Video', 'fileInfo.audio' => 'Audio', + 'fileInfo.subtitles' => 'Subtítulos', 'fileInfo.file' => 'Archivo', - 'fileInfo.advanced' => 'Avanzado', 'fileInfo.codec' => 'Códec', 'fileInfo.resolution' => 'Resolución', 'fileInfo.bitrate' => 'Tasa de bits', @@ -2574,7 +2573,6 @@ extension on TranslationsEs { 'fileInfo.colorPrimaries' => 'Primarias de color', 'fileInfo.chromaSubsampling' => 'Submuestreo de croma', 'fileInfo.channels' => 'Canales', - 'fileInfo.subtitles' => 'Subtítulos', 'fileInfo.overallBitrate' => 'Tasa de bits total', 'fileInfo.path' => 'Ruta', 'fileInfo.size' => 'Tamaño', @@ -2710,9 +2708,9 @@ extension on TranslationsEs { 'videoControls.language' => 'Idioma', 'videoControls.noSubtitlesFound' => 'No se encontraron subtítulos', 'videoControls.subtitleDownloaded' => 'Subtítulo descargado', + 'videoControls.subtitleDownloadedNotApplied' => 'El subtítulo se descargó, pero no se pudo seleccionar', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloadedNotApplied' => 'El subtítulo se descargó, pero no se pudo seleccionar', 'videoControls.subtitleDownloadFailed' => 'Error al descargar subtítulo', 'videoControls.searchLanguages' => 'Buscar idiomas...', 'messages.markedAsWatched' => 'Marcado como visto', @@ -3224,9 +3222,9 @@ extension on TranslationsEs { 'watchTogether.failedToJoin' => 'Error al unirse a la sesión', 'watchTogether.sessionCodeCopied' => 'Código de sesión copiado al portapapeles', 'watchTogether.relayUnreachable' => 'No se puede acceder al servidor de retransmisión. Es posible que tu proveedor de internet esté bloqueando Ver juntos.', + 'watchTogether.reconnectingToHost' => 'Reconectando con el anfitrión...', _ => null, } ?? switch (path) { - 'watchTogether.reconnectingToHost' => 'Reconectando con el anfitrión...', 'watchTogether.currentPlayback' => 'Reproducción actual', 'watchTogether.joinCurrentPlayback' => 'Unirse a la reproducción actual', 'watchTogether.joinCurrentPlaybackDescription' => 'Vuelve a lo que el anfitrión está viendo ahora mismo', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index db0c8087..316fbe70 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -507,8 +507,8 @@ class _Translations$fileInfo$fr extends Translations$fileInfo$en { @override String get title => 'Informations sur le fichier'; @override String get video => 'Vidéo'; @override String get audio => 'Audio'; + @override String get subtitles => 'Sous-titres'; @override String get file => 'Fichier'; - @override String get advanced => 'Avancé'; @override String get codec => 'Codec'; @override String get resolution => 'Résolution'; @override String get bitrate => 'Débit'; @@ -521,7 +521,6 @@ class _Translations$fileInfo$fr extends Translations$fileInfo$en { @override String get colorPrimaries => 'Couleurs primaires'; @override String get chromaSubsampling => 'Sous-échantillonnage chromatique'; @override String get channels => 'Canaux'; - @override String get subtitles => 'Sous-titres'; @override String get overallBitrate => 'Débit global'; @override String get path => 'Chemin'; @override String get size => 'Taille'; @@ -2560,8 +2559,8 @@ extension on TranslationsFr { 'fileInfo.title' => 'Informations sur le fichier', 'fileInfo.video' => 'Vidéo', 'fileInfo.audio' => 'Audio', + 'fileInfo.subtitles' => 'Sous-titres', 'fileInfo.file' => 'Fichier', - 'fileInfo.advanced' => 'Avancé', 'fileInfo.codec' => 'Codec', 'fileInfo.resolution' => 'Résolution', 'fileInfo.bitrate' => 'Débit', @@ -2574,7 +2573,6 @@ extension on TranslationsFr { 'fileInfo.colorPrimaries' => 'Couleurs primaires', 'fileInfo.chromaSubsampling' => 'Sous-échantillonnage chromatique', 'fileInfo.channels' => 'Canaux', - 'fileInfo.subtitles' => 'Sous-titres', 'fileInfo.overallBitrate' => 'Débit global', 'fileInfo.path' => 'Chemin', 'fileInfo.size' => 'Taille', @@ -2710,9 +2708,9 @@ extension on TranslationsFr { 'videoControls.language' => 'Langue', 'videoControls.noSubtitlesFound' => 'Aucun sous-titre trouvé', 'videoControls.subtitleDownloaded' => 'Sous-titre téléchargé', + 'videoControls.subtitleDownloadedNotApplied' => 'Le sous-titre a été téléchargé, mais n’a pas pu être sélectionné', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloadedNotApplied' => 'Le sous-titre a été téléchargé, mais n’a pas pu être sélectionné', 'videoControls.subtitleDownloadFailed' => 'Échec du téléchargement du sous-titre', 'videoControls.searchLanguages' => 'Rechercher des langues...', 'messages.markedAsWatched' => 'Marqué comme vu', @@ -3224,9 +3222,9 @@ extension on TranslationsFr { 'watchTogether.failedToJoin' => 'Échec de la connexion à la session', 'watchTogether.sessionCodeCopied' => 'Code de session copié dans le presse-papiers', 'watchTogether.relayUnreachable' => 'Serveur relais inaccessible. Un blocage par le fournisseur d’accès peut empêcher le fonctionnement de Regarder ensemble.', + 'watchTogether.reconnectingToHost' => 'Reconnexion à l\'hôte...', _ => null, } ?? switch (path) { - 'watchTogether.reconnectingToHost' => 'Reconnexion à l\'hôte...', 'watchTogether.currentPlayback' => 'Lecture en cours', 'watchTogether.joinCurrentPlayback' => 'Rejoindre la lecture en cours', 'watchTogether.joinCurrentPlaybackDescription' => 'Reprendre le contenu que l’hôte regarde actuellement', diff --git a/lib/i18n/strings_hu.g.dart b/lib/i18n/strings_hu.g.dart index c2367247..7a296f8f 100644 --- a/lib/i18n/strings_hu.g.dart +++ b/lib/i18n/strings_hu.g.dart @@ -507,8 +507,8 @@ class _Translations$fileInfo$hu extends Translations$fileInfo$en { @override String get title => 'Fájlinformáció'; @override String get video => 'Videó'; @override String get audio => 'Hang'; + @override String get subtitles => 'Feliratok'; @override String get file => 'Fájl'; - @override String get advanced => 'Haladó'; @override String get codec => 'Kodek'; @override String get resolution => 'Felbontás'; @override String get bitrate => 'Bitráta'; @@ -521,7 +521,6 @@ class _Translations$fileInfo$hu extends Translations$fileInfo$en { @override String get colorPrimaries => 'Elsődleges színek'; @override String get chromaSubsampling => 'Krominancia-alulmintavételezés'; @override String get channels => 'Csatornák'; - @override String get subtitles => 'Feliratok'; @override String get overallBitrate => 'Összesített bitráta'; @override String get path => 'Elérési út'; @override String get size => 'Méret'; @@ -2560,8 +2559,8 @@ extension on TranslationsHu { 'fileInfo.title' => 'Fájlinformáció', 'fileInfo.video' => 'Videó', 'fileInfo.audio' => 'Hang', + 'fileInfo.subtitles' => 'Feliratok', 'fileInfo.file' => 'Fájl', - 'fileInfo.advanced' => 'Haladó', 'fileInfo.codec' => 'Kodek', 'fileInfo.resolution' => 'Felbontás', 'fileInfo.bitrate' => 'Bitráta', @@ -2574,7 +2573,6 @@ extension on TranslationsHu { 'fileInfo.colorPrimaries' => 'Elsődleges színek', 'fileInfo.chromaSubsampling' => 'Krominancia-alulmintavételezés', 'fileInfo.channels' => 'Csatornák', - 'fileInfo.subtitles' => 'Feliratok', 'fileInfo.overallBitrate' => 'Összesített bitráta', 'fileInfo.path' => 'Elérési út', 'fileInfo.size' => 'Méret', @@ -2710,9 +2708,9 @@ extension on TranslationsHu { 'videoControls.language' => 'Nyelv', 'videoControls.noSubtitlesFound' => 'Nem találhatók feliratok', 'videoControls.subtitleDownloaded' => 'Felirat letöltve', + 'videoControls.subtitleDownloadedNotApplied' => 'Felirat letöltve, de nem sikerült kiválasztani', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloadedNotApplied' => 'Felirat letöltve, de nem sikerült kiválasztani', 'videoControls.subtitleDownloadFailed' => 'Nem sikerült a felirat letöltése', 'videoControls.searchLanguages' => 'Nyelvek keresése...', 'messages.markedAsWatched' => 'Megjelölve megtekintettként', @@ -3224,9 +3222,9 @@ extension on TranslationsHu { 'watchTogether.failedToJoin' => 'Nem sikerült csatlakozni a munkamenethez', 'watchTogether.sessionCodeCopied' => 'A munkamenetkód a vágólapra másolva', 'watchTogether.relayUnreachable' => 'A relészerver nem érhető el. Az internetszolgáltató blokkolása megakadályozhatja a közös nézést.', + 'watchTogether.reconnectingToHost' => 'Újracsatlakozás a házigazdához...', _ => null, } ?? switch (path) { - 'watchTogether.reconnectingToHost' => 'Újracsatlakozás a házigazdához...', 'watchTogether.currentPlayback' => 'Jelenlegi lejátszás', 'watchTogether.joinCurrentPlayback' => 'Csatlakozás a jelenlegi lejátszáshoz', 'watchTogether.joinCurrentPlaybackDescription' => 'Visszatérés ahhoz, amit a házigazda éppen néz', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 34376029..d1367857 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -507,8 +507,8 @@ class _Translations$fileInfo$it extends Translations$fileInfo$en { @override String get title => 'Info sul file'; @override String get video => 'Video'; @override String get audio => 'Audio'; + @override String get subtitles => 'Sottotitoli'; @override String get file => 'File'; - @override String get advanced => 'Avanzate'; @override String get codec => 'Codec'; @override String get resolution => 'Risoluzione'; @override String get bitrate => 'Bitrate'; @@ -521,7 +521,6 @@ class _Translations$fileInfo$it extends Translations$fileInfo$en { @override String get colorPrimaries => 'Colori primari'; @override String get chromaSubsampling => 'Sottocampionamento cromatico'; @override String get channels => 'Canali'; - @override String get subtitles => 'Sottotitoli'; @override String get overallBitrate => 'Bitrate complessivo'; @override String get path => 'Percorso'; @override String get size => 'Dimensione'; @@ -2560,8 +2559,8 @@ extension on TranslationsIt { 'fileInfo.title' => 'Info sul file', 'fileInfo.video' => 'Video', 'fileInfo.audio' => 'Audio', + 'fileInfo.subtitles' => 'Sottotitoli', 'fileInfo.file' => 'File', - 'fileInfo.advanced' => 'Avanzate', 'fileInfo.codec' => 'Codec', 'fileInfo.resolution' => 'Risoluzione', 'fileInfo.bitrate' => 'Bitrate', @@ -2574,7 +2573,6 @@ extension on TranslationsIt { 'fileInfo.colorPrimaries' => 'Colori primari', 'fileInfo.chromaSubsampling' => 'Sottocampionamento cromatico', 'fileInfo.channels' => 'Canali', - 'fileInfo.subtitles' => 'Sottotitoli', 'fileInfo.overallBitrate' => 'Bitrate complessivo', 'fileInfo.path' => 'Percorso', 'fileInfo.size' => 'Dimensione', @@ -2710,9 +2708,9 @@ extension on TranslationsIt { 'videoControls.language' => 'Lingua', 'videoControls.noSubtitlesFound' => 'Nessun sottotitolo trovato', 'videoControls.subtitleDownloaded' => 'Sottotitolo scaricato', + 'videoControls.subtitleDownloadedNotApplied' => 'Il sottotitolo è stato scaricato, ma non è stato possibile selezionarlo', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloadedNotApplied' => 'Il sottotitolo è stato scaricato, ma non è stato possibile selezionarlo', 'videoControls.subtitleDownloadFailed' => 'Impossibile scaricare il sottotitolo', 'videoControls.searchLanguages' => 'Cerca lingue...', 'messages.markedAsWatched' => 'Segnato come visto', @@ -3224,9 +3222,9 @@ extension on TranslationsIt { 'watchTogether.failedToJoin' => 'Impossibile unirsi alla sessione', 'watchTogether.sessionCodeCopied' => 'Codice della sessione copiato negli appunti', 'watchTogether.relayUnreachable' => 'Il server relay non è raggiungibile. Eventuali blocchi dell\'ISP potrebbero impedire l\'uso di Guarda insieme.', + 'watchTogether.reconnectingToHost' => 'Riconnessione all\'host...', _ => null, } ?? switch (path) { - 'watchTogether.reconnectingToHost' => 'Riconnessione all\'host...', 'watchTogether.currentPlayback' => 'Riproduzione corrente', 'watchTogether.joinCurrentPlayback' => 'Unisciti alla riproduzione corrente', 'watchTogether.joinCurrentPlaybackDescription' => 'Torna a ciò che l\'host sta guardando in questo momento', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index 5bbc6711..8037cabd 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -507,8 +507,8 @@ class _Translations$fileInfo$ja extends Translations$fileInfo$en { @override String get title => 'ファイル情報'; @override String get video => '映像'; @override String get audio => '音声'; + @override String get subtitles => '字幕'; @override String get file => 'ファイル'; - @override String get advanced => '詳細'; @override String get codec => 'コーデック'; @override String get resolution => '解像度'; @override String get bitrate => 'ビットレート'; @@ -521,7 +521,6 @@ class _Translations$fileInfo$ja extends Translations$fileInfo$en { @override String get colorPrimaries => '色原色'; @override String get chromaSubsampling => 'クロマサブサンプリング'; @override String get channels => 'チャンネル'; - @override String get subtitles => '字幕'; @override String get overallBitrate => '全体ビットレート'; @override String get path => 'パス'; @override String get size => 'サイズ'; @@ -2557,8 +2556,8 @@ extension on TranslationsJa { 'fileInfo.title' => 'ファイル情報', 'fileInfo.video' => '映像', 'fileInfo.audio' => '音声', + 'fileInfo.subtitles' => '字幕', 'fileInfo.file' => 'ファイル', - 'fileInfo.advanced' => '詳細', 'fileInfo.codec' => 'コーデック', 'fileInfo.resolution' => '解像度', 'fileInfo.bitrate' => 'ビットレート', @@ -2571,7 +2570,6 @@ extension on TranslationsJa { 'fileInfo.colorPrimaries' => '色原色', 'fileInfo.chromaSubsampling' => 'クロマサブサンプリング', 'fileInfo.channels' => 'チャンネル', - 'fileInfo.subtitles' => '字幕', 'fileInfo.overallBitrate' => '全体ビットレート', 'fileInfo.path' => 'パス', 'fileInfo.size' => 'サイズ', @@ -2707,9 +2705,9 @@ extension on TranslationsJa { 'videoControls.language' => '言語', 'videoControls.noSubtitlesFound' => '字幕が見つかりません', 'videoControls.subtitleDownloaded' => '字幕をダウンロードしました', + 'videoControls.subtitleDownloadedNotApplied' => '字幕はダウンロードされましたが、選択できませんでした', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloadedNotApplied' => '字幕はダウンロードされましたが、選択できませんでした', 'videoControls.subtitleDownloadFailed' => '字幕のダウンロードに失敗しました', 'videoControls.searchLanguages' => '言語を検索…', 'messages.markedAsWatched' => '視聴済みにしました', @@ -3221,9 +3219,9 @@ extension on TranslationsJa { 'watchTogether.failedToJoin' => 'セッションへの参加に失敗しました', 'watchTogether.sessionCodeCopied' => 'セッションコードをクリップボードにコピーしました', 'watchTogether.relayUnreachable' => 'リレーサーバーに接続できません。ISPによるブロックのため「一緒に見る」を利用できない可能性があります。', + 'watchTogether.reconnectingToHost' => 'ホストに再接続中…', _ => null, } ?? switch (path) { - 'watchTogether.reconnectingToHost' => 'ホストに再接続中…', 'watchTogether.currentPlayback' => '現在の再生', 'watchTogether.joinCurrentPlayback' => '現在の再生に参加', 'watchTogether.joinCurrentPlaybackDescription' => 'ホストが現在視聴中のコンテンツに戻る', diff --git a/lib/i18n/strings_kk.g.dart b/lib/i18n/strings_kk.g.dart index 617bab3a..051923bc 100644 --- a/lib/i18n/strings_kk.g.dart +++ b/lib/i18n/strings_kk.g.dart @@ -510,8 +510,8 @@ class _Translations$fileInfo$kk extends Translations$fileInfo$en { @override String get title => 'Файл туралы ақпарат'; @override String get video => 'Видео'; @override String get audio => 'Аудио'; + @override String get subtitles => 'Субтитрлер'; @override String get file => 'Файл'; - @override String get advanced => 'Кеңейтілген'; @override String get codec => 'Кодек'; @override String get resolution => 'Ажыратымдылық'; @override String get bitrate => 'Биттік жылдамдық (Bitrate)'; @@ -524,7 +524,6 @@ class _Translations$fileInfo$kk extends Translations$fileInfo$en { @override String get colorPrimaries => 'Негізгі түстер'; @override String get chromaSubsampling => 'Түстік субдискретизация'; @override String get channels => 'Арналар'; - @override String get subtitles => 'Субтитрлер'; @override String get overallBitrate => 'Жалпы биттік жылдамдық'; @override String get path => 'Жол'; @override String get size => 'Көлемі'; @@ -2574,8 +2573,8 @@ extension on TranslationsKk { 'fileInfo.title' => 'Файл туралы ақпарат', 'fileInfo.video' => 'Видео', 'fileInfo.audio' => 'Аудио', + 'fileInfo.subtitles' => 'Субтитрлер', 'fileInfo.file' => 'Файл', - 'fileInfo.advanced' => 'Кеңейтілген', 'fileInfo.codec' => 'Кодек', 'fileInfo.resolution' => 'Ажыратымдылық', 'fileInfo.bitrate' => 'Биттік жылдамдық (Bitrate)', @@ -2588,7 +2587,6 @@ extension on TranslationsKk { 'fileInfo.colorPrimaries' => 'Негізгі түстер', 'fileInfo.chromaSubsampling' => 'Түстік субдискретизация', 'fileInfo.channels' => 'Арналар', - 'fileInfo.subtitles' => 'Субтитрлер', 'fileInfo.overallBitrate' => 'Жалпы биттік жылдамдық', 'fileInfo.path' => 'Жол', 'fileInfo.size' => 'Көлемі', @@ -2721,9 +2719,9 @@ extension on TranslationsKk { 'videoControls.queue' => 'Кезек', 'videoControls.noQueueItems' => 'Кезекте элементтер жоқ', 'videoControls.searchSubtitles' => 'Субтитр іздеу', + 'videoControls.language' => 'Тіл', _ => null, } ?? switch (path) { - 'videoControls.language' => 'Тіл', 'videoControls.noSubtitlesFound' => 'Субтитр табылмады', 'videoControls.subtitleDownloaded' => 'Субтитр жүктелді', 'videoControls.subtitleDownloadedNotApplied' => 'Субтитр жүктелді, бірақ қолданылмады', @@ -3235,9 +3233,9 @@ extension on TranslationsKk { 'watchTogether.pasteFromClipboard' => 'Алмасу буферінен қою', 'watchTogether.pleaseEnterCode' => 'Сеанс кодын енгізіңіз', 'watchTogether.codeMustBe5Chars' => 'Сеанс коды 5 таңбадан тұруы керек', + 'watchTogether.joinInstructions' => 'Ұйымдастырушының сеанс кодын енгізіңіз.', _ => null, } ?? switch (path) { - 'watchTogether.joinInstructions' => 'Ұйымдастырушының сеанс кодын енгізіңіз.', 'watchTogether.failedToCreate' => 'Сеансты жасау мүмкін болмады', 'watchTogether.failedToJoin' => 'Сеансқа қосылу мүмкін болмады', 'watchTogether.sessionCodeCopied' => 'Сеанс коды көшірілді', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 58b2df30..65c732b7 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -507,8 +507,8 @@ class _Translations$fileInfo$ko extends Translations$fileInfo$en { @override String get title => '파일 정보'; @override String get video => '비디오'; @override String get audio => '오디오'; + @override String get subtitles => '자막'; @override String get file => '파일'; - @override String get advanced => '고급'; @override String get codec => '코덱'; @override String get resolution => '해상도'; @override String get bitrate => '비트레이트'; @@ -521,7 +521,6 @@ class _Translations$fileInfo$ko extends Translations$fileInfo$en { @override String get colorPrimaries => '색 원색'; @override String get chromaSubsampling => '색차 서브샘플링'; @override String get channels => '채널'; - @override String get subtitles => '자막'; @override String get overallBitrate => '전체 비트레이트'; @override String get path => '경로'; @override String get size => '크기'; @@ -2557,8 +2556,8 @@ extension on TranslationsKo { 'fileInfo.title' => '파일 정보', 'fileInfo.video' => '비디오', 'fileInfo.audio' => '오디오', + 'fileInfo.subtitles' => '자막', 'fileInfo.file' => '파일', - 'fileInfo.advanced' => '고급', 'fileInfo.codec' => '코덱', 'fileInfo.resolution' => '해상도', 'fileInfo.bitrate' => '비트레이트', @@ -2571,7 +2570,6 @@ extension on TranslationsKo { 'fileInfo.colorPrimaries' => '색 원색', 'fileInfo.chromaSubsampling' => '색차 서브샘플링', 'fileInfo.channels' => '채널', - 'fileInfo.subtitles' => '자막', 'fileInfo.overallBitrate' => '전체 비트레이트', 'fileInfo.path' => '경로', 'fileInfo.size' => '크기', @@ -2707,9 +2705,9 @@ extension on TranslationsKo { 'videoControls.language' => '언어', 'videoControls.noSubtitlesFound' => '자막을 찾을 수 없습니다', 'videoControls.subtitleDownloaded' => '자막이 다운로드되었습니다', + 'videoControls.subtitleDownloadedNotApplied' => '자막을 다운로드했지만 선택할 수 없습니다', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloadedNotApplied' => '자막을 다운로드했지만 선택할 수 없습니다', 'videoControls.subtitleDownloadFailed' => '자막 다운로드에 실패했습니다', 'videoControls.searchLanguages' => '언어 검색...', 'messages.markedAsWatched' => '시청 완료로 표시됨', @@ -3221,9 +3219,9 @@ extension on TranslationsKo { 'watchTogether.failedToJoin' => '세션 참여 실패', 'watchTogether.sessionCodeCopied' => '세션 코드가 클립보드에 복사되었습니다', 'watchTogether.relayUnreachable' => '릴레이 서버에 연결할 수 없습니다. ISP 차단으로 함께 보기를 사용하지 못할 수 있습니다.', + 'watchTogether.reconnectingToHost' => '호스트에 재연결 중...', _ => null, } ?? switch (path) { - 'watchTogether.reconnectingToHost' => '호스트에 재연결 중...', 'watchTogether.currentPlayback' => '현재 재생', 'watchTogether.joinCurrentPlayback' => '현재 재생 참여', 'watchTogether.joinCurrentPlaybackDescription' => '호스트가 현재 시청 중인 콘텐츠로 이동합니다', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index 3e1e15b1..7e4c8bc5 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -507,8 +507,8 @@ class _Translations$fileInfo$nb extends Translations$fileInfo$en { @override String get title => 'Filinformasjon'; @override String get video => 'Video'; @override String get audio => 'Lyd'; + @override String get subtitles => 'Undertekster'; @override String get file => 'Fil'; - @override String get advanced => 'Avansert'; @override String get codec => 'Kodek'; @override String get resolution => 'Oppløsning'; @override String get bitrate => 'Bitrate'; @@ -521,7 +521,6 @@ class _Translations$fileInfo$nb extends Translations$fileInfo$en { @override String get colorPrimaries => 'Fargeprimærer'; @override String get chromaSubsampling => 'Krominansnedsampling'; @override String get channels => 'Kanaler'; - @override String get subtitles => 'Undertekster'; @override String get overallBitrate => 'Total bitrate'; @override String get path => 'Sti'; @override String get size => 'Størrelse'; @@ -2560,8 +2559,8 @@ extension on TranslationsNb { 'fileInfo.title' => 'Filinformasjon', 'fileInfo.video' => 'Video', 'fileInfo.audio' => 'Lyd', + 'fileInfo.subtitles' => 'Undertekster', 'fileInfo.file' => 'Fil', - 'fileInfo.advanced' => 'Avansert', 'fileInfo.codec' => 'Kodek', 'fileInfo.resolution' => 'Oppløsning', 'fileInfo.bitrate' => 'Bitrate', @@ -2574,7 +2573,6 @@ extension on TranslationsNb { 'fileInfo.colorPrimaries' => 'Fargeprimærer', 'fileInfo.chromaSubsampling' => 'Krominansnedsampling', 'fileInfo.channels' => 'Kanaler', - 'fileInfo.subtitles' => 'Undertekster', 'fileInfo.overallBitrate' => 'Total bitrate', 'fileInfo.path' => 'Sti', 'fileInfo.size' => 'Størrelse', @@ -2710,9 +2708,9 @@ extension on TranslationsNb { 'videoControls.language' => 'Språk', 'videoControls.noSubtitlesFound' => 'Ingen undertekster funnet', 'videoControls.subtitleDownloaded' => 'Undertekst lastet ned', + 'videoControls.subtitleDownloadedNotApplied' => 'Underteksten ble lastet ned, men kunne ikke velges', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloadedNotApplied' => 'Underteksten ble lastet ned, men kunne ikke velges', 'videoControls.subtitleDownloadFailed' => 'Kunne ikke laste ned undertekst', 'videoControls.searchLanguages' => 'Søk etter språk...', 'messages.markedAsWatched' => 'Merket som sett', @@ -3224,9 +3222,9 @@ extension on TranslationsNb { 'watchTogether.failedToJoin' => 'Kunne ikke bli med i økt', 'watchTogether.sessionCodeCopied' => 'Øktkode kopiert til utklippstavle', 'watchTogether.relayUnreachable' => 'Reléserveren kan ikke nås. Blokkering hos internettleverandøren kan hindre Se sammen.', + 'watchTogether.reconnectingToHost' => 'Kobler til verten på nytt...', _ => null, } ?? switch (path) { - 'watchTogether.reconnectingToHost' => 'Kobler til verten på nytt...', 'watchTogether.currentPlayback' => 'Gjeldende avspilling', 'watchTogether.joinCurrentPlayback' => 'Bli med i gjeldende avspilling', 'watchTogether.joinCurrentPlaybackDescription' => 'Hopp tilbake til det verten ser på nå', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 4e20f02a..b29c68ee 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -507,8 +507,8 @@ class _Translations$fileInfo$nl extends Translations$fileInfo$en { @override String get title => 'Bestandsinformatie'; @override String get video => 'Video'; @override String get audio => 'Audio'; + @override String get subtitles => 'Ondertitels'; @override String get file => 'Bestand'; - @override String get advanced => 'Geavanceerd'; @override String get codec => 'Codec'; @override String get resolution => 'Resolutie'; @override String get bitrate => 'Bitrate'; @@ -521,7 +521,6 @@ class _Translations$fileInfo$nl extends Translations$fileInfo$en { @override String get colorPrimaries => 'Kleurprimaires'; @override String get chromaSubsampling => 'Chroma-subsampling'; @override String get channels => 'Kanalen'; - @override String get subtitles => 'Ondertitels'; @override String get overallBitrate => 'Totale bitrate'; @override String get path => 'Pad'; @override String get size => 'Grootte'; @@ -2560,8 +2559,8 @@ extension on TranslationsNl { 'fileInfo.title' => 'Bestandsinformatie', 'fileInfo.video' => 'Video', 'fileInfo.audio' => 'Audio', + 'fileInfo.subtitles' => 'Ondertitels', 'fileInfo.file' => 'Bestand', - 'fileInfo.advanced' => 'Geavanceerd', 'fileInfo.codec' => 'Codec', 'fileInfo.resolution' => 'Resolutie', 'fileInfo.bitrate' => 'Bitrate', @@ -2574,7 +2573,6 @@ extension on TranslationsNl { 'fileInfo.colorPrimaries' => 'Kleurprimaires', 'fileInfo.chromaSubsampling' => 'Chroma-subsampling', 'fileInfo.channels' => 'Kanalen', - 'fileInfo.subtitles' => 'Ondertitels', 'fileInfo.overallBitrate' => 'Totale bitrate', 'fileInfo.path' => 'Pad', 'fileInfo.size' => 'Grootte', @@ -2710,9 +2708,9 @@ extension on TranslationsNl { 'videoControls.language' => 'Taal', 'videoControls.noSubtitlesFound' => 'Geen ondertitels gevonden', 'videoControls.subtitleDownloaded' => 'Ondertitel gedownload', + 'videoControls.subtitleDownloadedNotApplied' => 'De ondertiteling is gedownload, maar kon niet worden geselecteerd', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloadedNotApplied' => 'De ondertiteling is gedownload, maar kon niet worden geselecteerd', 'videoControls.subtitleDownloadFailed' => 'Ondertitel downloaden mislukt', 'videoControls.searchLanguages' => 'Talen zoeken...', 'messages.markedAsWatched' => 'Gemarkeerd als gekeken', @@ -3224,9 +3222,9 @@ extension on TranslationsNl { 'watchTogether.failedToJoin' => 'Deelnemen aan sessie mislukt', 'watchTogether.sessionCodeCopied' => 'Sessiecode naar het klembord gekopieerd', 'watchTogether.relayUnreachable' => 'De relayserver is onbereikbaar. Een blokkering door je internetprovider kan Samen kijken verhinderen.', + 'watchTogether.reconnectingToHost' => 'Opnieuw verbinden met host...', _ => null, } ?? switch (path) { - 'watchTogether.reconnectingToHost' => 'Opnieuw verbinden met host...', 'watchTogether.currentPlayback' => 'Wat nu wordt afgespeeld', 'watchTogether.joinCurrentPlayback' => 'Deelnemen aan huidige weergave', 'watchTogether.joinCurrentPlaybackDescription' => 'Ga terug naar wat de host nu kijkt', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index ed867b64..2c5441cd 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -507,8 +507,8 @@ class _Translations$fileInfo$pl extends Translations$fileInfo$en { @override String get title => 'Informacje o pliku'; @override String get video => 'Wideo'; @override String get audio => 'Audio'; + @override String get subtitles => 'Napisy'; @override String get file => 'Plik'; - @override String get advanced => 'Zaawansowane'; @override String get codec => 'Kodek'; @override String get resolution => 'Rozdzielczość'; @override String get bitrate => 'Przepływność'; @@ -521,7 +521,6 @@ class _Translations$fileInfo$pl extends Translations$fileInfo$en { @override String get colorPrimaries => 'Kolory podstawowe'; @override String get chromaSubsampling => 'Podpróbkowanie chrominancji'; @override String get channels => 'Kanały'; - @override String get subtitles => 'Napisy'; @override String get overallBitrate => 'Całkowita przepływność'; @override String get path => 'Ścieżka'; @override String get size => 'Rozmiar'; @@ -2566,8 +2565,8 @@ extension on TranslationsPl { 'fileInfo.title' => 'Informacje o pliku', 'fileInfo.video' => 'Wideo', 'fileInfo.audio' => 'Audio', + 'fileInfo.subtitles' => 'Napisy', 'fileInfo.file' => 'Plik', - 'fileInfo.advanced' => 'Zaawansowane', 'fileInfo.codec' => 'Kodek', 'fileInfo.resolution' => 'Rozdzielczość', 'fileInfo.bitrate' => 'Przepływność', @@ -2580,7 +2579,6 @@ extension on TranslationsPl { 'fileInfo.colorPrimaries' => 'Kolory podstawowe', 'fileInfo.chromaSubsampling' => 'Podpróbkowanie chrominancji', 'fileInfo.channels' => 'Kanały', - 'fileInfo.subtitles' => 'Napisy', 'fileInfo.overallBitrate' => 'Całkowita przepływność', 'fileInfo.path' => 'Ścieżka', 'fileInfo.size' => 'Rozmiar', @@ -2716,9 +2714,9 @@ extension on TranslationsPl { 'videoControls.language' => 'Język', 'videoControls.noSubtitlesFound' => 'Nie znaleziono napisów', 'videoControls.subtitleDownloaded' => 'Napisy pobrane', + 'videoControls.subtitleDownloadedNotApplied' => 'Napisy zostały pobrane, ale nie można ich było wybrać', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloadedNotApplied' => 'Napisy zostały pobrane, ale nie można ich było wybrać', 'videoControls.subtitleDownloadFailed' => 'Nie udało się pobrać napisów', 'videoControls.searchLanguages' => 'Szukaj języków...', 'messages.markedAsWatched' => 'Oznaczono jako obejrzane', @@ -3230,9 +3228,9 @@ extension on TranslationsPl { 'watchTogether.failedToJoin' => 'Nie udało się dołączyć do sesji', 'watchTogether.sessionCodeCopied' => 'Kod sesji skopiowany do schowka', 'watchTogether.relayUnreachable' => 'Serwer pośredniczący jest nieosiągalny. Blokada operatora internetowego może uniemożliwiać korzystanie z funkcji „Oglądaj razem”.', + 'watchTogether.reconnectingToHost' => 'Ponowne łączenie z gospodarzem...', _ => null, } ?? switch (path) { - 'watchTogether.reconnectingToHost' => 'Ponowne łączenie z gospodarzem...', 'watchTogether.currentPlayback' => 'Bieżące odtwarzanie', 'watchTogether.joinCurrentPlayback' => 'Dołącz do bieżącego odtwarzania', 'watchTogether.joinCurrentPlaybackDescription' => 'Wróć do treści oglądanej obecnie przez gospodarza', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index cfef78e6..378c97a0 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -507,8 +507,8 @@ class _Translations$fileInfo$pt extends Translations$fileInfo$en { @override String get title => 'Informações do arquivo'; @override String get video => 'Vídeo'; @override String get audio => 'Áudio'; + @override String get subtitles => 'Legendas'; @override String get file => 'Arquivo'; - @override String get advanced => 'Avançado'; @override String get codec => 'Codec'; @override String get resolution => 'Resolução'; @override String get bitrate => 'Taxa de bits'; @@ -521,7 +521,6 @@ class _Translations$fileInfo$pt extends Translations$fileInfo$en { @override String get colorPrimaries => 'Primárias de Cor'; @override String get chromaSubsampling => 'Subamostragem de Croma'; @override String get channels => 'Canais'; - @override String get subtitles => 'Legendas'; @override String get overallBitrate => 'Taxa de bits total'; @override String get path => 'Caminho'; @override String get size => 'Tamanho'; @@ -2560,8 +2559,8 @@ extension on TranslationsPt { 'fileInfo.title' => 'Informações do arquivo', 'fileInfo.video' => 'Vídeo', 'fileInfo.audio' => 'Áudio', + 'fileInfo.subtitles' => 'Legendas', 'fileInfo.file' => 'Arquivo', - 'fileInfo.advanced' => 'Avançado', 'fileInfo.codec' => 'Codec', 'fileInfo.resolution' => 'Resolução', 'fileInfo.bitrate' => 'Taxa de bits', @@ -2574,7 +2573,6 @@ extension on TranslationsPt { 'fileInfo.colorPrimaries' => 'Primárias de Cor', 'fileInfo.chromaSubsampling' => 'Subamostragem de Croma', 'fileInfo.channels' => 'Canais', - 'fileInfo.subtitles' => 'Legendas', 'fileInfo.overallBitrate' => 'Taxa de bits total', 'fileInfo.path' => 'Caminho', 'fileInfo.size' => 'Tamanho', @@ -2710,9 +2708,9 @@ extension on TranslationsPt { 'videoControls.language' => 'Idioma', 'videoControls.noSubtitlesFound' => 'Nenhuma legenda encontrada', 'videoControls.subtitleDownloaded' => 'Legenda baixada', + 'videoControls.subtitleDownloadedNotApplied' => 'A legenda foi baixada, mas não foi possível selecioná-la', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloadedNotApplied' => 'A legenda foi baixada, mas não foi possível selecioná-la', 'videoControls.subtitleDownloadFailed' => 'Falha ao baixar legenda', 'videoControls.searchLanguages' => 'Pesquisar idiomas...', 'messages.markedAsWatched' => 'Marcado como assistido', @@ -3224,9 +3222,9 @@ extension on TranslationsPt { 'watchTogether.failedToJoin' => 'Falha ao entrar na sessão', 'watchTogether.sessionCodeCopied' => 'Código da sessão copiado para a área de transferência', 'watchTogether.relayUnreachable' => 'Servidor de retransmissão inacessível. O bloqueio pelo provedor de internet pode impedir o uso do Assistir Juntos.', + 'watchTogether.reconnectingToHost' => 'Reconectando ao anfitrião...', _ => null, } ?? switch (path) { - 'watchTogether.reconnectingToHost' => 'Reconectando ao anfitrião...', 'watchTogether.currentPlayback' => 'Reprodução atual', 'watchTogether.joinCurrentPlayback' => 'Entrar na reprodução atual', 'watchTogether.joinCurrentPlaybackDescription' => 'Voltar ao conteúdo que o anfitrião está assistindo agora', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index 11ebbc3d..ef7c5a7d 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -507,8 +507,8 @@ class _Translations$fileInfo$ru extends Translations$fileInfo$en { @override String get title => 'Информация о файле'; @override String get video => 'Видео'; @override String get audio => 'Аудио'; + @override String get subtitles => 'Субтитры'; @override String get file => 'Файл'; - @override String get advanced => 'Дополнительно'; @override String get codec => 'Кодек'; @override String get resolution => 'Разрешение'; @override String get bitrate => 'Битрейт'; @@ -521,7 +521,6 @@ class _Translations$fileInfo$ru extends Translations$fileInfo$en { @override String get colorPrimaries => 'Основные цвета'; @override String get chromaSubsampling => 'Субдискретизация цветности'; @override String get channels => 'Каналы'; - @override String get subtitles => 'Субтитры'; @override String get overallBitrate => 'Общий битрейт'; @override String get path => 'Путь'; @override String get size => 'Размер'; @@ -2566,8 +2565,8 @@ extension on TranslationsRu { 'fileInfo.title' => 'Информация о файле', 'fileInfo.video' => 'Видео', 'fileInfo.audio' => 'Аудио', + 'fileInfo.subtitles' => 'Субтитры', 'fileInfo.file' => 'Файл', - 'fileInfo.advanced' => 'Дополнительно', 'fileInfo.codec' => 'Кодек', 'fileInfo.resolution' => 'Разрешение', 'fileInfo.bitrate' => 'Битрейт', @@ -2580,7 +2579,6 @@ extension on TranslationsRu { 'fileInfo.colorPrimaries' => 'Основные цвета', 'fileInfo.chromaSubsampling' => 'Субдискретизация цветности', 'fileInfo.channels' => 'Каналы', - 'fileInfo.subtitles' => 'Субтитры', 'fileInfo.overallBitrate' => 'Общий битрейт', 'fileInfo.path' => 'Путь', 'fileInfo.size' => 'Размер', @@ -2716,9 +2714,9 @@ extension on TranslationsRu { 'videoControls.language' => 'Язык', 'videoControls.noSubtitlesFound' => 'Субтитры не найдены', 'videoControls.subtitleDownloaded' => 'Субтитры загружены', + 'videoControls.subtitleDownloadedNotApplied' => 'Субтитры загружены, но их не удалось выбрать', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloadedNotApplied' => 'Субтитры загружены, но их не удалось выбрать', 'videoControls.subtitleDownloadFailed' => 'Не удалось загрузить субтитры', 'videoControls.searchLanguages' => 'Поиск языков...', 'messages.markedAsWatched' => 'Отмечено как просмотренное', @@ -3230,9 +3228,9 @@ extension on TranslationsRu { 'watchTogether.failedToJoin' => 'Не удалось присоединиться к сессии', 'watchTogether.sessionCodeCopied' => 'Код сессии скопирован в буфер обмена', 'watchTogether.relayUnreachable' => 'Сервер ретрансляции недоступен. Блокировка интернет-провайдером может помешать совместному просмотру.', + 'watchTogether.reconnectingToHost' => 'Повторное подключение к организатору...', _ => null, } ?? switch (path) { - 'watchTogether.reconnectingToHost' => 'Повторное подключение к организатору...', 'watchTogether.currentPlayback' => 'Текущее воспроизведение', 'watchTogether.joinCurrentPlayback' => 'Присоединиться к текущему воспроизведению', 'watchTogether.joinCurrentPlaybackDescription' => 'Вернуться к материалу, который сейчас смотрит организатор', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index aacb2d63..ee95d8c6 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -507,8 +507,8 @@ class _Translations$fileInfo$sv extends Translations$fileInfo$en { @override String get title => 'Filinformation'; @override String get video => 'Video'; @override String get audio => 'Ljud'; + @override String get subtitles => 'Undertexter'; @override String get file => 'Fil'; - @override String get advanced => 'Avancerat'; @override String get codec => 'Kodek'; @override String get resolution => 'Upplösning'; @override String get bitrate => 'Bithastighet'; @@ -521,7 +521,6 @@ class _Translations$fileInfo$sv extends Translations$fileInfo$en { @override String get colorPrimaries => 'Färgprimärer'; @override String get chromaSubsampling => 'Krominansnedsampling'; @override String get channels => 'Kanaler'; - @override String get subtitles => 'Undertexter'; @override String get overallBitrate => 'Total bithastighet'; @override String get path => 'Sökväg'; @override String get size => 'Storlek'; @@ -2560,8 +2559,8 @@ extension on TranslationsSv { 'fileInfo.title' => 'Filinformation', 'fileInfo.video' => 'Video', 'fileInfo.audio' => 'Ljud', + 'fileInfo.subtitles' => 'Undertexter', 'fileInfo.file' => 'Fil', - 'fileInfo.advanced' => 'Avancerat', 'fileInfo.codec' => 'Kodek', 'fileInfo.resolution' => 'Upplösning', 'fileInfo.bitrate' => 'Bithastighet', @@ -2574,7 +2573,6 @@ extension on TranslationsSv { 'fileInfo.colorPrimaries' => 'Färgprimärer', 'fileInfo.chromaSubsampling' => 'Krominansnedsampling', 'fileInfo.channels' => 'Kanaler', - 'fileInfo.subtitles' => 'Undertexter', 'fileInfo.overallBitrate' => 'Total bithastighet', 'fileInfo.path' => 'Sökväg', 'fileInfo.size' => 'Storlek', @@ -2710,9 +2708,9 @@ extension on TranslationsSv { 'videoControls.language' => 'Språk', 'videoControls.noSubtitlesFound' => 'Inga undertexter hittades', 'videoControls.subtitleDownloaded' => 'Undertexten har laddats ned', + 'videoControls.subtitleDownloadedNotApplied' => 'Undertexten laddades ned men kunde inte väljas', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloadedNotApplied' => 'Undertexten laddades ned men kunde inte väljas', 'videoControls.subtitleDownloadFailed' => 'Det gick inte att ladda ned undertexten', 'videoControls.searchLanguages' => 'Sök språk...', 'messages.markedAsWatched' => 'Markerad som sedd', @@ -3224,9 +3222,9 @@ extension on TranslationsSv { 'watchTogether.failedToJoin' => 'Det gick inte att gå med i sessionen', 'watchTogether.sessionCodeCopied' => 'Sessionskoden har kopierats till urklipp', 'watchTogether.relayUnreachable' => 'Reläservern kan inte nås. Din internetleverantör kan blockera Titta tillsammans.', + 'watchTogether.reconnectingToHost' => 'Återansluter till värden...', _ => null, } ?? switch (path) { - 'watchTogether.reconnectingToHost' => 'Återansluter till värden...', 'watchTogether.currentPlayback' => 'Aktuell uppspelning', 'watchTogether.joinCurrentPlayback' => 'Gå med i aktuell uppspelning', 'watchTogether.joinCurrentPlaybackDescription' => 'Hoppa tillbaka till det värden tittar på just nu', diff --git a/lib/i18n/strings_tr.g.dart b/lib/i18n/strings_tr.g.dart index 2f70ce84..a6c199c1 100644 --- a/lib/i18n/strings_tr.g.dart +++ b/lib/i18n/strings_tr.g.dart @@ -510,8 +510,8 @@ class _Translations$fileInfo$tr extends Translations$fileInfo$en { @override String get title => 'Dosya Bilgisi'; @override String get video => 'Video'; @override String get audio => 'Ses'; + @override String get subtitles => 'Altyazılar'; @override String get file => 'Dosya'; - @override String get advanced => 'Gelişmiş'; @override String get codec => 'Kodek'; @override String get resolution => 'Çözünürlük'; @override String get bitrate => 'Bit Hızı (Bitrate)'; @@ -524,7 +524,6 @@ class _Translations$fileInfo$tr extends Translations$fileInfo$en { @override String get colorPrimaries => 'Ana Renkler'; @override String get chromaSubsampling => 'Renk Alt Örneklemesi'; @override String get channels => 'Kanallar'; - @override String get subtitles => 'Altyazılar'; @override String get overallBitrate => 'Genel Bit Hızı'; @override String get path => 'Yol'; @override String get size => 'Boyut'; @@ -2574,8 +2573,8 @@ extension on TranslationsTr { 'fileInfo.title' => 'Dosya Bilgisi', 'fileInfo.video' => 'Video', 'fileInfo.audio' => 'Ses', + 'fileInfo.subtitles' => 'Altyazılar', 'fileInfo.file' => 'Dosya', - 'fileInfo.advanced' => 'Gelişmiş', 'fileInfo.codec' => 'Kodek', 'fileInfo.resolution' => 'Çözünürlük', 'fileInfo.bitrate' => 'Bit Hızı (Bitrate)', @@ -2588,7 +2587,6 @@ extension on TranslationsTr { 'fileInfo.colorPrimaries' => 'Ana Renkler', 'fileInfo.chromaSubsampling' => 'Renk Alt Örneklemesi', 'fileInfo.channels' => 'Kanallar', - 'fileInfo.subtitles' => 'Altyazılar', 'fileInfo.overallBitrate' => 'Genel Bit Hızı', 'fileInfo.path' => 'Yol', 'fileInfo.size' => 'Boyut', @@ -2721,9 +2719,9 @@ extension on TranslationsTr { 'videoControls.queue' => 'Kuyruk', 'videoControls.noQueueItems' => 'Kuyrukta öge yok', 'videoControls.searchSubtitles' => 'Altyazı Ara', + 'videoControls.language' => 'Dil', _ => null, } ?? switch (path) { - 'videoControls.language' => 'Dil', 'videoControls.noSubtitlesFound' => 'Altyazı bulunamadı', 'videoControls.subtitleDownloaded' => 'Altyazı indirildi', 'videoControls.subtitleDownloadedNotApplied' => 'Altyazı indirildi ancak seçilemedi', @@ -3235,9 +3233,9 @@ extension on TranslationsTr { 'watchTogether.pasteFromClipboard' => 'Panodan yapıştır', 'watchTogether.pleaseEnterCode' => 'Lütfen bir oturum kodu girin', 'watchTogether.codeMustBe5Chars' => 'Oturum kodu 5 karakter olmalıdır', + 'watchTogether.joinInstructions' => 'Katılmak için kurucunun oturum kodunu girin.', _ => null, } ?? switch (path) { - 'watchTogether.joinInstructions' => 'Katılmak için kurucunun oturum kodunu girin.', 'watchTogether.failedToCreate' => 'Oturum oluşturulamadı', 'watchTogether.failedToJoin' => 'Oturuma katılınamadı', 'watchTogether.sessionCodeCopied' => 'Oturum kodu panoya kopyalandı', diff --git a/lib/i18n/strings_uz.g.dart b/lib/i18n/strings_uz.g.dart index d53719b0..62b93c07 100644 --- a/lib/i18n/strings_uz.g.dart +++ b/lib/i18n/strings_uz.g.dart @@ -510,8 +510,8 @@ class _Translations$fileInfo$uz extends Translations$fileInfo$en { @override String get title => 'Fayl haqida maʼlumot'; @override String get video => 'Video'; @override String get audio => 'Audio'; + @override String get subtitles => 'Subtitrlar'; @override String get file => 'Fayl'; - @override String get advanced => 'Kengaytirilgan'; @override String get codec => 'Kodek'; @override String get resolution => 'Oʻlchamlari (Resolution)'; @override String get bitrate => 'Bitreyt (Bitrate)'; @@ -524,7 +524,6 @@ class _Translations$fileInfo$uz extends Translations$fileInfo$en { @override String get colorPrimaries => 'Asosiy ranglar'; @override String get chromaSubsampling => 'Rangli subdiskretlash'; @override String get channels => 'Kanallar'; - @override String get subtitles => 'Subtitrlar'; @override String get overallBitrate => 'Umumiy bitreyt'; @override String get path => 'Yoʻl'; @override String get size => 'Hajmi'; @@ -2574,8 +2573,8 @@ extension on TranslationsUz { 'fileInfo.title' => 'Fayl haqida maʼlumot', 'fileInfo.video' => 'Video', 'fileInfo.audio' => 'Audio', + 'fileInfo.subtitles' => 'Subtitrlar', 'fileInfo.file' => 'Fayl', - 'fileInfo.advanced' => 'Kengaytirilgan', 'fileInfo.codec' => 'Kodek', 'fileInfo.resolution' => 'Oʻlchamlari (Resolution)', 'fileInfo.bitrate' => 'Bitreyt (Bitrate)', @@ -2588,7 +2587,6 @@ extension on TranslationsUz { 'fileInfo.colorPrimaries' => 'Asosiy ranglar', 'fileInfo.chromaSubsampling' => 'Rangli subdiskretlash', 'fileInfo.channels' => 'Kanallar', - 'fileInfo.subtitles' => 'Subtitrlar', 'fileInfo.overallBitrate' => 'Umumiy bitreyt', 'fileInfo.path' => 'Yoʻl', 'fileInfo.size' => 'Hajmi', @@ -2721,9 +2719,9 @@ extension on TranslationsUz { 'videoControls.queue' => 'Navbat', 'videoControls.noQueueItems' => 'Navbatda elementlar yoʻq', 'videoControls.searchSubtitles' => 'Subtitr qidirish', + 'videoControls.language' => 'Til', _ => null, } ?? switch (path) { - 'videoControls.language' => 'Til', 'videoControls.noSubtitlesFound' => 'Subtitr topilmadi', 'videoControls.subtitleDownloaded' => 'Subtitr yuklab olindi', 'videoControls.subtitleDownloadedNotApplied' => 'Subtitr yuklab olindi, lekin qoʻllanilmadi', @@ -3235,9 +3233,9 @@ extension on TranslationsUz { 'watchTogether.pasteFromClipboard' => 'Xotiradan joylash', 'watchTogether.pleaseEnterCode' => 'Seans kodini kiriting', 'watchTogether.codeMustBe5Chars' => 'Seans kodi 5 ta belgidan iborat boʻlishi kerak', + 'watchTogether.joinInstructions' => 'Tashkilotchining seans kodini kiriting.', _ => null, } ?? switch (path) { - 'watchTogether.joinInstructions' => 'Tashkilotchining seans kodini kiriting.', 'watchTogether.failedToCreate' => 'Seansni yaratib boʻlmadi', 'watchTogether.failedToJoin' => 'Seansga qoʻshilib boʻlmadi', 'watchTogether.sessionCodeCopied' => 'Seans kodi nusxalandi', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 32f98971..6cec799d 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -507,8 +507,8 @@ class Translations$fileInfo$zh extends Translations$fileInfo$en { @override String get title => '文件信息'; @override String get video => '视频'; @override String get audio => '音频'; + @override String get subtitles => '字幕'; @override String get file => '文件'; - @override String get advanced => '高级'; @override String get codec => '编解码器'; @override String get resolution => '分辨率'; @override String get bitrate => '比特率'; @@ -521,7 +521,6 @@ class Translations$fileInfo$zh extends Translations$fileInfo$en { @override String get colorPrimaries => '色彩基色'; @override String get chromaSubsampling => '色度子采样'; @override String get channels => '声道'; - @override String get subtitles => '字幕'; @override String get overallBitrate => '总比特率'; @override String get path => '路径'; @override String get size => '大小'; @@ -2557,8 +2556,8 @@ extension on TranslationsZh { 'fileInfo.title' => '文件信息', 'fileInfo.video' => '视频', 'fileInfo.audio' => '音频', + 'fileInfo.subtitles' => '字幕', 'fileInfo.file' => '文件', - 'fileInfo.advanced' => '高级', 'fileInfo.codec' => '编解码器', 'fileInfo.resolution' => '分辨率', 'fileInfo.bitrate' => '比特率', @@ -2571,7 +2570,6 @@ extension on TranslationsZh { 'fileInfo.colorPrimaries' => '色彩基色', 'fileInfo.chromaSubsampling' => '色度子采样', 'fileInfo.channels' => '声道', - 'fileInfo.subtitles' => '字幕', 'fileInfo.overallBitrate' => '总比特率', 'fileInfo.path' => '路径', 'fileInfo.size' => '大小', @@ -2707,9 +2705,9 @@ extension on TranslationsZh { 'videoControls.language' => '语言', 'videoControls.noSubtitlesFound' => '未找到字幕', 'videoControls.subtitleDownloaded' => '字幕已下载', + 'videoControls.subtitleDownloadedNotApplied' => '字幕已下载,但无法选择', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloadedNotApplied' => '字幕已下载,但无法选择', 'videoControls.subtitleDownloadFailed' => '字幕下载失败', 'videoControls.searchLanguages' => '搜索语言…', 'messages.markedAsWatched' => '已标记为已观看', @@ -3221,9 +3219,9 @@ extension on TranslationsZh { 'watchTogether.failedToJoin' => '加入会话失败', 'watchTogether.sessionCodeCopied' => '会话代码已复制到剪贴板', 'watchTogether.relayUnreachable' => '无法访问中继服务器。网络运营商的屏蔽可能导致“一起看”不可用。', + 'watchTogether.reconnectingToHost' => '正在重新连接到主持人…', _ => null, } ?? switch (path) { - 'watchTogether.reconnectingToHost' => '正在重新连接到主持人…', 'watchTogether.currentPlayback' => '当前播放', 'watchTogether.joinCurrentPlayback' => '加入当前播放', 'watchTogether.joinCurrentPlaybackDescription' => '加入主持人当前正在观看的内容', diff --git a/lib/i18n/strings_zh_Hant.g.dart b/lib/i18n/strings_zh_Hant.g.dart index 84f1bfa0..52da540a 100644 --- a/lib/i18n/strings_zh_Hant.g.dart +++ b/lib/i18n/strings_zh_Hant.g.dart @@ -508,8 +508,8 @@ class _Translations$fileInfo$zh_Hant extends Translations$fileInfo$zh { @override String get title => '檔案資訊'; @override String get video => '影片'; @override String get audio => '音訊'; + @override String get subtitles => '字幕'; @override String get file => '檔案'; - @override String get advanced => '進階'; @override String get codec => '編解碼器'; @override String get resolution => '解析度'; @override String get bitrate => '位元率'; @@ -522,7 +522,6 @@ class _Translations$fileInfo$zh_Hant extends Translations$fileInfo$zh { @override String get colorPrimaries => '色彩基色'; @override String get chromaSubsampling => '色度抽樣'; @override String get channels => '聲道數'; - @override String get subtitles => '字幕'; @override String get overallBitrate => '總位元率'; @override String get path => '路徑'; @override String get size => '大小'; @@ -2558,8 +2557,8 @@ extension on TranslationsZhHant { 'fileInfo.title' => '檔案資訊', 'fileInfo.video' => '影片', 'fileInfo.audio' => '音訊', + 'fileInfo.subtitles' => '字幕', 'fileInfo.file' => '檔案', - 'fileInfo.advanced' => '進階', 'fileInfo.codec' => '編解碼器', 'fileInfo.resolution' => '解析度', 'fileInfo.bitrate' => '位元率', @@ -2572,7 +2571,6 @@ extension on TranslationsZhHant { 'fileInfo.colorPrimaries' => '色彩基色', 'fileInfo.chromaSubsampling' => '色度抽樣', 'fileInfo.channels' => '聲道數', - 'fileInfo.subtitles' => '字幕', 'fileInfo.overallBitrate' => '總位元率', 'fileInfo.path' => '路徑', 'fileInfo.size' => '大小', @@ -2708,9 +2706,9 @@ extension on TranslationsZhHant { 'videoControls.language' => '語言', 'videoControls.noSubtitlesFound' => '找不到字幕', 'videoControls.subtitleDownloaded' => '字幕下載成功', + 'videoControls.subtitleDownloadedNotApplied' => '字幕已下載,但無法套用', _ => null, } ?? switch (path) { - 'videoControls.subtitleDownloadedNotApplied' => '字幕已下載,但無法套用', 'videoControls.subtitleDownloadFailed' => '字幕下載失敗', 'videoControls.searchLanguages' => '搜尋語言…', 'messages.markedAsWatched' => '已標記為已觀看', @@ -3222,9 +3220,9 @@ extension on TranslationsZhHant { 'watchTogether.failedToJoin' => '加入工作階段失敗', 'watchTogether.sessionCodeCopied' => '工作階段代碼已複製到剪貼簿', 'watchTogether.relayUnreachable' => '無法連線至中繼伺服器。ISP 封鎖可能會導致「一起看」無法使用。', + 'watchTogether.reconnectingToHost' => '正在重新連線至主持人…', _ => null, } ?? switch (path) { - 'watchTogether.reconnectingToHost' => '正在重新連線至主持人…', 'watchTogether.currentPlayback' => '目前播放內容', 'watchTogether.joinCurrentPlayback' => '加入目前播放點', 'watchTogether.joinCurrentPlaybackDescription' => '同步至主持人目前的觀看進度', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 5bb65aed..2170d4b6 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -381,30 +381,118 @@ }, "fileInfo": { "title": "Filinformation", + "overview": "", "video": "Video", "audio": "Ljud", + "subtitles": "Undertexter", + "images": "", + "dataStreams": "", + "lyrics": "", "file": "Fil", - "advanced": "Avancerat", + "attachments": "", + "delivery": "", + "versionCounter": "", + "fileCounter": "", + "noStreams": "", + "copyPath": "", + "pathCopied": "", "codec": "Kodek", + "codecTag": "", "resolution": "Upplösning", + "codedResolution": "", "bitrate": "Bithastighet", "frameRate": "Bildfrekvens", + "rotation": "", + "comment": "", + "audioDescription": "", + "headerCompression": "", + "sidecarFile": "", + "transportTimestamp": "", + "displayOffset": "", + "previewFailureCode": "", + "previewRetries": "", "aspectRatio": "Bildförhållande", + "pixelAspectRatio": "", "profile": "Profil", + "level": "", "bitDepth": "Bitdjup", + "pixelFormat": "", "colorSpace": "Färgrymd", "colorRange": "Färgområde", "colorPrimaries": "Färgprimärer", + "colorTransfer": "", "chromaSubsampling": "Krominansnedsampling", + "chromaLocation": "", + "scanType": "", + "interlaced": "", + "anamorphic": "", + "referenceFrames": "", + "dynamicRange": "", + "dolbyVision": "", + "dolbyVisionLevel": "", + "dolbyVisionVersion": "", + "dolbyVisionLayers": "", + "baseLayerCompatibility": "", + "avcBitstream": "", + "nalLengthSize": "", + "scalingMatrix": "", + "streamIdentifier": "", + "streamIndex": "", + "streamId": "", + "language": "", + "languageCode": "", + "streamTitle": "", "channels": "Kanaler", - "subtitles": "Undertexter", + "sampleRate": "", + "spatialAudio": "", + "textBased": "", + "subtitleFormat": "", + "provider": "", + "matchScore": "", + "externalDelivery": "", + "sidecarPath": "", + "sourceStream": "", + "temporary": "", + "timeBase": "", "overallBitrate": "Total bithastighet", "path": "Sökväg", + "fileName": "", "size": "Storlek", + "totalSize": "", "container": "Container", "duration": "Varaktighet", + "previewThumbnails": "", + "previewIndex": "", + "packetLength": "", + "filePresent": "", + "fileReadable": "", + "streamPath": "", "optimizedForStreaming": "Optimerad för streaming", - "has64bitOffsets": "64-bitars offsetvärden" + "has64bitOffsets": "64-bitars offsetvärden", + "protocol": "", + "mediaType": "", + "sourceKind": "", + "optimizedVersion": "", + "optimizationTarget": "", + "deletedAt": "", + "remoteSource": "", + "infiniteStream": "", + "directPlay": "", + "directStream": "", + "transcoding": "", + "etag": "", + "versionId": "", + "fileId": "", + "defaultAudioTrack": "", + "defaultSubtitleTrack": "", + "subtitlesOff": "", + "flagDefault": "", + "flagForced": "", + "flagSelected": "", + "flagExternal": "", + "flagHearingImpaired": "", + "flagDub": "", + "flagOriginal": "" }, "mediaMenu": { "markAsWatched": "Markera som sedd", diff --git a/lib/i18n/tr.i18n.json b/lib/i18n/tr.i18n.json index cf814789..89592960 100644 --- a/lib/i18n/tr.i18n.json +++ b/lib/i18n/tr.i18n.json @@ -381,30 +381,118 @@ }, "fileInfo": { "title": "Dosya Bilgisi", + "overview": "", "video": "Video", "audio": "Ses", + "subtitles": "Altyazılar", + "images": "", + "dataStreams": "", + "lyrics": "", "file": "Dosya", - "advanced": "Gelişmiş", + "attachments": "", + "delivery": "", + "versionCounter": "", + "fileCounter": "", + "noStreams": "", + "copyPath": "", + "pathCopied": "", "codec": "Kodek", + "codecTag": "", "resolution": "Çözünürlük", + "codedResolution": "", "bitrate": "Bit Hızı (Bitrate)", "frameRate": "Kare Hızı", + "rotation": "", + "comment": "", + "audioDescription": "", + "headerCompression": "", + "sidecarFile": "", + "transportTimestamp": "", + "displayOffset": "", + "previewFailureCode": "", + "previewRetries": "", "aspectRatio": "En Boy Oranı", + "pixelAspectRatio": "", "profile": "Profil", + "level": "", "bitDepth": "Bit Derinliği", + "pixelFormat": "", "colorSpace": "Renk Alanı", "colorRange": "Renk Aralığı", "colorPrimaries": "Ana Renkler", + "colorTransfer": "", "chromaSubsampling": "Renk Alt Örneklemesi", + "chromaLocation": "", + "scanType": "", + "interlaced": "", + "anamorphic": "", + "referenceFrames": "", + "dynamicRange": "", + "dolbyVision": "", + "dolbyVisionLevel": "", + "dolbyVisionVersion": "", + "dolbyVisionLayers": "", + "baseLayerCompatibility": "", + "avcBitstream": "", + "nalLengthSize": "", + "scalingMatrix": "", + "streamIdentifier": "", + "streamIndex": "", + "streamId": "", + "language": "", + "languageCode": "", + "streamTitle": "", "channels": "Kanallar", - "subtitles": "Altyazılar", + "sampleRate": "", + "spatialAudio": "", + "textBased": "", + "subtitleFormat": "", + "provider": "", + "matchScore": "", + "externalDelivery": "", + "sidecarPath": "", + "sourceStream": "", + "temporary": "", + "timeBase": "", "overallBitrate": "Genel Bit Hızı", "path": "Yol", + "fileName": "", "size": "Boyut", + "totalSize": "", "container": "Kapsayıcı", "duration": "Süre", + "previewThumbnails": "", + "previewIndex": "", + "packetLength": "", + "filePresent": "", + "fileReadable": "", + "streamPath": "", "optimizedForStreaming": "Akış İçin Optimize Edilmiş", - "has64bitOffsets": "64-bit Ofsetler" + "has64bitOffsets": "64-bit Ofsetler", + "protocol": "", + "mediaType": "", + "sourceKind": "", + "optimizedVersion": "", + "optimizationTarget": "", + "deletedAt": "", + "remoteSource": "", + "infiniteStream": "", + "directPlay": "", + "directStream": "", + "transcoding": "", + "etag": "", + "versionId": "", + "fileId": "", + "defaultAudioTrack": "", + "defaultSubtitleTrack": "", + "subtitlesOff": "", + "flagDefault": "", + "flagForced": "", + "flagSelected": "", + "flagExternal": "", + "flagHearingImpaired": "", + "flagDub": "", + "flagOriginal": "" }, "mediaMenu": { "markAsWatched": "İzlendi Olarak İşaretle", diff --git a/lib/i18n/uz.i18n.json b/lib/i18n/uz.i18n.json index 77669650..2c1284bb 100644 --- a/lib/i18n/uz.i18n.json +++ b/lib/i18n/uz.i18n.json @@ -381,30 +381,118 @@ }, "fileInfo": { "title": "Fayl haqida maʼlumot", + "overview": "", "video": "Video", "audio": "Audio", + "subtitles": "Subtitrlar", + "images": "", + "dataStreams": "", + "lyrics": "", "file": "Fayl", - "advanced": "Kengaytirilgan", + "attachments": "", + "delivery": "", + "versionCounter": "", + "fileCounter": "", + "noStreams": "", + "copyPath": "", + "pathCopied": "", "codec": "Kodek", + "codecTag": "", "resolution": "Oʻlchamlari (Resolution)", + "codedResolution": "", "bitrate": "Bitreyt (Bitrate)", "frameRate": "Kadrlar chastotasi", + "rotation": "", + "comment": "", + "audioDescription": "", + "headerCompression": "", + "sidecarFile": "", + "transportTimestamp": "", + "displayOffset": "", + "previewFailureCode": "", + "previewRetries": "", "aspectRatio": "Tomonlar nisbati", + "pixelAspectRatio": "", "profile": "Profil", + "level": "", "bitDepth": "Bit chuqurligi", + "pixelFormat": "", "colorSpace": "Rang makoni", "colorRange": "Rang diapazoni", "colorPrimaries": "Asosiy ranglar", + "colorTransfer": "", "chromaSubsampling": "Rangli subdiskretlash", + "chromaLocation": "", + "scanType": "", + "interlaced": "", + "anamorphic": "", + "referenceFrames": "", + "dynamicRange": "", + "dolbyVision": "", + "dolbyVisionLevel": "", + "dolbyVisionVersion": "", + "dolbyVisionLayers": "", + "baseLayerCompatibility": "", + "avcBitstream": "", + "nalLengthSize": "", + "scalingMatrix": "", + "streamIdentifier": "", + "streamIndex": "", + "streamId": "", + "language": "", + "languageCode": "", + "streamTitle": "", "channels": "Kanallar", - "subtitles": "Subtitrlar", + "sampleRate": "", + "spatialAudio": "", + "textBased": "", + "subtitleFormat": "", + "provider": "", + "matchScore": "", + "externalDelivery": "", + "sidecarPath": "", + "sourceStream": "", + "temporary": "", + "timeBase": "", "overallBitrate": "Umumiy bitreyt", "path": "Yoʻl", + "fileName": "", "size": "Hajmi", + "totalSize": "", "container": "Konteyner", "duration": "Davomiyligi", + "previewThumbnails": "", + "previewIndex": "", + "packetLength": "", + "filePresent": "", + "fileReadable": "", + "streamPath": "", "optimizedForStreaming": "Oqimli uzatish uchun optimallashtirilgan", - "has64bitOffsets": "64-bitli siljishlar" + "has64bitOffsets": "64-bitli siljishlar", + "protocol": "", + "mediaType": "", + "sourceKind": "", + "optimizedVersion": "", + "optimizationTarget": "", + "deletedAt": "", + "remoteSource": "", + "infiniteStream": "", + "directPlay": "", + "directStream": "", + "transcoding": "", + "etag": "", + "versionId": "", + "fileId": "", + "defaultAudioTrack": "", + "defaultSubtitleTrack": "", + "subtitlesOff": "", + "flagDefault": "", + "flagForced": "", + "flagSelected": "", + "flagExternal": "", + "flagHearingImpaired": "", + "flagDub": "", + "flagOriginal": "" }, "mediaMenu": { "markAsWatched": "Koʻrilgan deb belgilash", diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json index 85b1f730..ff7e0b49 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -381,30 +381,118 @@ }, "fileInfo": { "title": "檔案資訊", + "overview": "", "video": "影片", "audio": "音訊", + "subtitles": "字幕", + "images": "", + "dataStreams": "", + "lyrics": "", "file": "檔案", - "advanced": "進階", + "attachments": "", + "delivery": "", + "versionCounter": "", + "fileCounter": "", + "noStreams": "", + "copyPath": "", + "pathCopied": "", "codec": "編解碼器", + "codecTag": "", "resolution": "解析度", + "codedResolution": "", "bitrate": "位元率", "frameRate": "影格率", + "rotation": "", + "comment": "", + "audioDescription": "", + "headerCompression": "", + "sidecarFile": "", + "transportTimestamp": "", + "displayOffset": "", + "previewFailureCode": "", + "previewRetries": "", "aspectRatio": "寬高比", + "pixelAspectRatio": "", "profile": "規格檔(Profile)", + "level": "", "bitDepth": "位元深度", + "pixelFormat": "", "colorSpace": "色彩空間", "colorRange": "色彩範圍", "colorPrimaries": "色彩基色", + "colorTransfer": "", "chromaSubsampling": "色度抽樣", + "chromaLocation": "", + "scanType": "", + "interlaced": "", + "anamorphic": "", + "referenceFrames": "", + "dynamicRange": "", + "dolbyVision": "", + "dolbyVisionLevel": "", + "dolbyVisionVersion": "", + "dolbyVisionLayers": "", + "baseLayerCompatibility": "", + "avcBitstream": "", + "nalLengthSize": "", + "scalingMatrix": "", + "streamIdentifier": "", + "streamIndex": "", + "streamId": "", + "language": "", + "languageCode": "", + "streamTitle": "", "channels": "聲道數", - "subtitles": "字幕", + "sampleRate": "", + "spatialAudio": "", + "textBased": "", + "subtitleFormat": "", + "provider": "", + "matchScore": "", + "externalDelivery": "", + "sidecarPath": "", + "sourceStream": "", + "temporary": "", + "timeBase": "", "overallBitrate": "總位元率", "path": "路徑", + "fileName": "", "size": "大小", + "totalSize": "", "container": "封裝格式", "duration": "長度", + "previewThumbnails": "", + "previewIndex": "", + "packetLength": "", + "filePresent": "", + "fileReadable": "", + "streamPath": "", "optimizedForStreaming": "已最佳化串流播放", - "has64bitOffsets": "具 64 位元偏移量" + "has64bitOffsets": "具 64 位元偏移量", + "protocol": "", + "mediaType": "", + "sourceKind": "", + "optimizedVersion": "", + "optimizationTarget": "", + "deletedAt": "", + "remoteSource": "", + "infiniteStream": "", + "directPlay": "", + "directStream": "", + "transcoding": "", + "etag": "", + "versionId": "", + "fileId": "", + "defaultAudioTrack": "", + "defaultSubtitleTrack": "", + "subtitlesOff": "", + "flagDefault": "", + "flagForced": "", + "flagSelected": "", + "flagExternal": "", + "flagHearingImpaired": "", + "flagDub": "", + "flagOriginal": "" }, "mediaMenu": { "markAsWatched": "標記為已觀看", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 694be396..017e43ab 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -381,30 +381,118 @@ }, "fileInfo": { "title": "文件信息", + "overview": "", "video": "视频", "audio": "音频", + "subtitles": "字幕", + "images": "", + "dataStreams": "", + "lyrics": "", "file": "文件", - "advanced": "高级", + "attachments": "", + "delivery": "", + "versionCounter": "", + "fileCounter": "", + "noStreams": "", + "copyPath": "", + "pathCopied": "", "codec": "编解码器", + "codecTag": "", "resolution": "分辨率", + "codedResolution": "", "bitrate": "比特率", "frameRate": "帧率", + "rotation": "", + "comment": "", + "audioDescription": "", + "headerCompression": "", + "sidecarFile": "", + "transportTimestamp": "", + "displayOffset": "", + "previewFailureCode": "", + "previewRetries": "", "aspectRatio": "宽高比", + "pixelAspectRatio": "", "profile": "编码配置", + "level": "", "bitDepth": "位深度", + "pixelFormat": "", "colorSpace": "色彩空间", "colorRange": "色彩范围", "colorPrimaries": "色彩基色", + "colorTransfer": "", "chromaSubsampling": "色度子采样", + "chromaLocation": "", + "scanType": "", + "interlaced": "", + "anamorphic": "", + "referenceFrames": "", + "dynamicRange": "", + "dolbyVision": "", + "dolbyVisionLevel": "", + "dolbyVisionVersion": "", + "dolbyVisionLayers": "", + "baseLayerCompatibility": "", + "avcBitstream": "", + "nalLengthSize": "", + "scalingMatrix": "", + "streamIdentifier": "", + "streamIndex": "", + "streamId": "", + "language": "", + "languageCode": "", + "streamTitle": "", "channels": "声道", - "subtitles": "字幕", + "sampleRate": "", + "spatialAudio": "", + "textBased": "", + "subtitleFormat": "", + "provider": "", + "matchScore": "", + "externalDelivery": "", + "sidecarPath": "", + "sourceStream": "", + "temporary": "", + "timeBase": "", "overallBitrate": "总比特率", "path": "路径", + "fileName": "", "size": "大小", + "totalSize": "", "container": "容器", "duration": "时长", + "previewThumbnails": "", + "previewIndex": "", + "packetLength": "", + "filePresent": "", + "fileReadable": "", + "streamPath": "", "optimizedForStreaming": "已针对流式传输优化", - "has64bitOffsets": "64 位偏移量" + "has64bitOffsets": "64 位偏移量", + "protocol": "", + "mediaType": "", + "sourceKind": "", + "optimizedVersion": "", + "optimizationTarget": "", + "deletedAt": "", + "remoteSource": "", + "infiniteStream": "", + "directPlay": "", + "directStream": "", + "transcoding": "", + "etag": "", + "versionId": "", + "fileId": "", + "defaultAudioTrack": "", + "defaultSubtitleTrack": "", + "subtitlesOff": "", + "flagDefault": "", + "flagForced": "", + "flagSelected": "", + "flagExternal": "", + "flagHearingImpaired": "", + "flagDub": "", + "flagOriginal": "" }, "mediaMenu": { "markAsWatched": "标记为已观看", diff --git a/lib/media/media_file_info.dart b/lib/media/media_file_info.dart index 1af945b1..98015647 100644 --- a/lib/media/media_file_info.dart +++ b/lib/media/media_file_info.dart @@ -1,121 +1,606 @@ import '../utils/formatters.dart'; -import 'media_source_info.dart'; -/// Backend-neutral file-info payload rendered by [FileInfoBottomSheet]. +/// Backend-neutral file-info payload rendered by `FileInfoBottomSheet`. /// -/// Both Plex and Jellyfin can populate any subset of these fields; rows that -/// don't apply to the active backend are left null and skipped at render -/// time. Plex fills the full set from `/library/metadata/{id}` (Media + Part -/// + Stream); Jellyfin fills the subset that's available on inline -/// `MediaSources`. +/// The shape mirrors what both media servers actually return: an item owns one +/// or more *versions* (Plex `Media`, Jellyfin `MediaSources`), a version owns +/// one or more *parts* (Plex `Part`; Jellyfin folds the file into the source, +/// so it always yields exactly one), and a part owns the demuxed *streams*. +/// Nothing is collapsed to "the first entry" — split/stacked files and +/// multi-version libraries surface every entry. +/// +/// Every field either backend exposes about the file itself is modelled here. +/// Deliberately excluded are Jellyfin's ffmpeg-input plumbing flags +/// (`IgnoreDts`, `IgnoreIndex`, `GenPtsInput`, `ReadAtNativeFramerate`, +/// `RequiresOpening`/`Closing`/`Looping`, `SupportsProbing`, +/// `UseMostCompatibleTranscodingProfile`, `TranscodingSubProtocol`, +/// `HasSegments`) and its `Localized*` label helpers: they describe the +/// transcoder contract or duplicate a flag already modelled, not the file. class MediaFileInfo { - // Media level properties + /// Every version the server reports, in server order. + final List versions; + + const MediaFileInfo({this.versions = const []}); +} + +/// One playable version of an item: Plex `Media`, Jellyfin `MediaSources[]`. +/// +/// The video/audio summary fields are the server's own roll-up of the primary +/// streams. They are kept because Plex derives them independently of the +/// stream table (an item can carry a `Media.videoResolution` label with no +/// probed streams at all), and they are what the version picker shows. +class MediaFileVersion { + final String? id; + + /// Server-assigned version label (Plex `Media.title`, Jellyfin + /// `MediaSource.Name`). + final String? title; + final String? container; - final String? videoCodec; - final String? videoResolution; - final String? videoFrameRate; - final String? videoProfile; + + /// Overall bitrate in kbps. Jellyfin reports bps and is normalised here. + final int? bitrateKbps; + + final int? durationMs; final int? width; final int? height; final double? aspectRatio; - final int? bitrate; - final int? duration; + + /// Server resolution label (`1080`, `4k`, `sd`). + final String? videoResolutionLabel; + + final String? videoCodec; + final String? videoProfile; + + /// Server frame-rate label (Plex `24p`, `NTSC`). + final String? videoFrameRateLabel; + final String? audioCodec; final String? audioProfile; final int? audioChannels; + final bool? optimizedForStreaming; final bool? has64bitOffsets; - // Part level properties (file) - final String? filePath; - final int? fileSize; + /// Jellyfin `Protocol` (`File`, `Http`, …). + final String? protocol; - // Stream level properties (video stream details) - final String? colorSpace; - final String? colorRange; - final String? colorPrimaries; - final String? chromaSubsampling; - final double? frameRate; - final int? bitDepth; - final int? videoBitrate; - final String? audioChannelLayout; + /// Jellyfin `VideoType` (`VideoFile`, `Dvd`, `BluRay`, `Iso`). + final String? videoType; - // Multi-track support - final List audioTracks; - final List subtitleTracks; + final bool? isRemote; + final bool? isInfiniteStream; + final bool? supportsDirectPlay; + final bool? supportsDirectStream; + final bool? supportsTranscoding; - MediaFileInfo({ + /// Jellyfin content ETag — the identity the server invalidates caches on. + final String? eTag; + + /// Jellyfin default stream selection for this source. `-1` on the subtitle + /// index is an explicit "start with subtitles off". + final int? defaultAudioStreamIndex; + final int? defaultSubtitleStreamIndex; + + /// Jellyfin `MediaSource.Type` (`Default`, `Grouping`, `Placeholder`). + final String? sourceType; + + /// Plex `proxyType == 42` — this version is a server-generated optimized + /// copy rather than the original file. + final bool? isOptimizedVersion; + + /// Plex `target` — the optimization profile that produced this version. + final String? optimizationTarget; + + /// Plex `deletedAt` (epoch seconds). Plex keeps listing an optimized + /// version it has already deleted, and that is exactly what a user + /// staring at a missing file needs to see. + final DateTime? deletedAt; + + /// Jellyfin `Timestamp` — transport-stream timestamp mode (`None`, `Zero`, + /// `Valid`) for TS containers. + final String? transportStreamTimestamp; + + /// Plex `displayOffset` — where this version starts inside a multi-part + /// presentation, as a percentage. + final int? displayOffsetPercent; + + final List parts; + + /// Embedded non-media files (Jellyfin `MediaAttachments`) — typically the + /// fonts an ASS subtitle track needs. + final List attachments; + + const MediaFileVersion({ + this.id, + this.title, this.container, - this.videoCodec, - this.videoResolution, - this.videoFrameRate, - this.videoProfile, + this.bitrateKbps, + this.durationMs, this.width, this.height, this.aspectRatio, - this.bitrate, - this.duration, + this.videoResolutionLabel, + this.videoCodec, + this.videoProfile, + this.videoFrameRateLabel, this.audioCodec, this.audioProfile, this.audioChannels, this.optimizedForStreaming, this.has64bitOffsets, - this.filePath, - this.fileSize, - this.colorSpace, - this.colorRange, - this.colorPrimaries, - this.chromaSubsampling, - this.frameRate, - this.bitDepth, - this.videoBitrate, - this.audioChannelLayout, - this.audioTracks = const [], - this.subtitleTracks = const [], + this.protocol, + this.videoType, + this.isRemote, + this.isInfiniteStream, + this.supportsDirectPlay, + this.supportsDirectStream, + this.supportsTranscoding, + this.eTag, + this.defaultAudioStreamIndex, + this.defaultSubtitleStreamIndex, + this.sourceType, + this.isOptimizedVersion, + this.optimizationTarget, + this.deletedAt, + this.transportStreamTimestamp, + this.displayOffsetPercent, + this.parts = const [], + this.attachments = const [], }); - String? get fileSizeFormatted { - if (fileSize == null) return null; - return ByteFormatter.formatBytes(fileSize!, decimals: 2); - } + String? get bitrateFormatted => bitrateKbps == null ? null : ByteFormatter.formatBitrate(bitrateKbps!); - String? get durationFormatted { - if (duration == null) return null; - final seconds = duration! ~/ 1000; - final hours = seconds ~/ 3600; - final minutes = (seconds % 3600) ~/ 60; - final secs = seconds % 60; - return hours > 0 ? '${hours}h ${minutes}m ${secs}s' : '${minutes}m ${secs}s'; - } - - String? get bitrateFormatted { - if (bitrate == null) return null; - return ByteFormatter.formatBitrate(bitrate!); - } - - String? get videoBitrateFormatted { - if (videoBitrate == null) return null; - return ByteFormatter.formatBitrate(videoBitrate!); - } + String? get durationFormatted => formatMediaDuration(durationMs); + /// `1920x1080` when the server probed dimensions, otherwise its own label. String? get resolutionFormatted { if (width != null && height != null) return '${width}x$height'; - if (videoResolution != null) return videoResolution; - return null; + return videoResolutionLabel; } String? get aspectRatioFormatted => aspectRatio?.toStringAsFixed(2); + int get totalFileSize => parts.fold(0, (sum, part) => sum + (part.fileSize ?? 0)); + + String? get totalFileSizeFormatted { + final total = totalFileSize; + return total == 0 ? null : ByteFormatter.formatBytes(total, decimals: 2); + } +} + +/// One file backing a version: Plex `Part`, or the Jellyfin source's own file. +class MediaFilePart { + final String? id; + final String? filePath; + final int? fileSize; + final String? container; + final int? durationMs; + final bool? optimizedForStreaming; + final bool? has64bitOffsets; + + /// Plex: the server generated BIF/preview thumbnails for this part. + final bool? hasThumbnail; + + /// Plex `indexes` — which preview-thumbnail index exists (`sd`). + final String? indexes; + + /// Plex `packetLength` for transport-stream containers. + final int? packetLength; + + /// Plex `failureBIFResultCode` / `failureBIFRetryCount` — why the server + /// has no preview thumbnails for this file, and how often it has retried. + final int? previewFailureCode; + final int? previewRetryCount; + + /// Plex `exists` / `accessible` — whether the file is still on disk and + /// whether the server can read it. Only populated when the metadata was + /// fetched with `checkFiles=1`, which is what the app's Plex client does. + final bool? exists; + final bool? accessible; + + /// Plex `Part.key` — the server-relative streaming path for this file. + final String? streamKey; + + final List streams; + + const MediaFilePart({ + this.id, + this.filePath, + this.fileSize, + this.container, + this.durationMs, + this.optimizedForStreaming, + this.has64bitOffsets, + this.hasThumbnail, + this.indexes, + this.packetLength, + + this.previewFailureCode, + this.previewRetryCount, + this.exists, + this.accessible, + this.streamKey, + this.streams = const [], + }); + + String? get fileSizeFormatted => fileSize == null ? null : ByteFormatter.formatBytes(fileSize!, decimals: 2); + + String? get durationFormatted => formatMediaDuration(durationMs); + + /// Trailing path segment — the filename the user recognises. + String? get fileName { + final path = filePath; + if (path == null || path.isEmpty) return null; + final separator = path.contains('\\') ? '\\' : '/'; + final segments = path.split(separator).where((segment) => segment.isNotEmpty); + return segments.isEmpty ? path : segments.last; + } + + Iterable streamsOfKind(MediaStreamKind kind) => streams.where((s) => s.kind == kind); +} + +/// Embedded attachment (Jellyfin `MediaAttachments`). +class MediaFileAttachment { + final int? index; + final String? fileName; + final String? mimeType; + final String? codec; + + const MediaFileAttachment({this.index, this.fileName, this.mimeType, this.codec}); +} + +enum MediaStreamKind { video, audio, subtitle, image, data, lyric, unknown } + +/// Colour/HDR classification of a video stream, normalised across backends. +/// +/// Jellyfin reports it directly (`VideoRangeType`); Plex is derived from the +/// transfer characteristics plus the Dolby Vision block. +enum MediaVideoRange { + sdr, + hdr, + hdr10, + hdr10Plus, + hlg, + dolbyVision, + dolbyVisionHdr10, + dolbyVisionHlg, + unknown; + + /// Technical label. Left untranslated on purpose — these are the trade + /// names people match against their display's spec sheet, exactly like the + /// codec strings next to them. + String? get label => switch (this) { + MediaVideoRange.sdr => 'SDR', + MediaVideoRange.hdr => 'HDR', + MediaVideoRange.hdr10 => 'HDR10', + MediaVideoRange.hdr10Plus => 'HDR10+', + MediaVideoRange.hlg => 'HLG', + MediaVideoRange.dolbyVision => 'Dolby Vision', + MediaVideoRange.dolbyVisionHdr10 => 'Dolby Vision (HDR10)', + MediaVideoRange.dolbyVisionHlg => 'Dolby Vision (HLG)', + MediaVideoRange.unknown => null, + }; +} + +/// Dolby Vision descriptor. Plex exposes it as `DOVI*` keys on the video +/// stream, Jellyfin as `Dv*`/`*PresentFlag`. +class MediaDolbyVisionInfo { + final int? profile; + final int? level; + final String? version; + final int? blCompatibilityId; + final bool? blPresent; + final bool? elPresent; + final bool? rpuPresent; + + /// Jellyfin's own summary string (`Dolby Vision Profile 8.1 (HDR10)`). + final String? title; + + const MediaDolbyVisionInfo({ + this.profile, + this.level, + this.version, + this.blCompatibilityId, + this.blPresent, + this.elPresent, + this.rpuPresent, + this.title, + }); + + bool get isEmpty => + profile == null && + level == null && + version == null && + blCompatibilityId == null && + blPresent == null && + elPresent == null && + rpuPresent == null && + title == null; + + /// `Profile 8.1` — the shorthand people actually quote. + String? get profileFormatted { + if (profile == null) return title; + final compatibility = blCompatibilityId; + return compatibility == null || compatibility == 0 ? 'Profile $profile' : 'Profile $profile.$compatibility'; + } +} + +/// One demuxed stream of a part. A single class covers video/audio/subtitle so +/// the sheet can render any stream the server reports — including the image +/// and data streams both backends occasionally expose — without a per-kind +/// parallel hierarchy. Fields that don't apply to a kind stay null. +class MediaStreamDetails { + final MediaStreamKind kind; + + /// Backend stream id (Plex `Stream.id`, Jellyfin has none). + final String? id; + + /// Container stream ordinal. + final int? index; + + /// 1-based ordinal among streams of the same kind, for display. + final int ordinal; + + final String? title; + + /// Server-rendered summary (Plex `extendedDisplayTitle`, Jellyfin + /// `DisplayTitle`). + final String? displayTitle; + + final String? codec; + + /// FourCC / container codec tag (Plex `codecID`, Jellyfin `CodecTag`). + final String? codecTag; + + final String? profile; + final int? level; + + /// Human-readable language (Plex `language`, Jellyfin `DisplayLanguage`). + final String? language; + + /// ISO 639 code as the server reports it. + final String? languageCode; + + /// Stream bitrate in kbps. Jellyfin reports bps and is normalised here. + final int? bitrateKbps; + + final bool? isDefault; + final bool? isForced; + final bool? isSelected; + final bool? isExternal; + final bool? isHearingImpaired; + + /// Plex dubbed/original audio flags. + final bool? isDub; + final bool? isOriginal; + + // ---- video ---- + final int? width; + final int? height; + final int? codedWidth; + final int? codedHeight; + final double? frameRate; + final String? scanType; + final bool? isInterlaced; + final int? bitDepth; + final int? refFrames; + final String? pixelFormat; + final String? colorSpace; + final String? colorRange; + final String? colorPrimaries; + final String? colorTransfer; + final String? chromaSubsampling; + final String? chromaLocation; + + /// Display aspect ratio as the server words it (`16:9`, `2.35:1`). + final String? aspectRatio; + + final String? pixelAspectRatio; + final bool? isAnamorphic; + final MediaVideoRange videoRange; + final bool? hasHdr10Plus; + final MediaDolbyVisionInfo? dolbyVision; + + /// Jellyfin `IsAVC` — whether an H.264 stream uses the AVC (not Annex B) + /// bitstream layout, with its NAL length prefix size. + final bool? isAvc; + final int? nalLengthSize; + + /// Plex `hasScalingMatrix` — H.264 custom scaling matrices, which some + /// hardware decoders reject. + final bool? hasScalingMatrix; + + /// Transport-stream PID / program identifier. + final String? streamIdentifier; + + /// Free-form ffmpeg stream comment tag (Plex `comment`, Jellyfin + /// `Comment` on embedded cover art). + final String? comment; + + /// Clockwise display rotation in degrees (Jellyfin `Rotation`). + final int? rotation; + + /// Plex `descriptions` — the track carries audio descriptions for the + /// visually impaired. + final bool? hasDescriptions; + + /// Plex `headerCompression` — the Matroska track uses header stripping, + /// which some demuxers reject. + final bool? headerCompression; + + /// On-disk path of a sidecar stream (Jellyfin `Path` on external subtitle + /// and lyric streams). + final String? filePath; + + /// Jellyfin `TimeBase` — the stream's ffmpeg time base (`1/24000`). + final String? timeBase; + + // ---- audio ---- + final int? channels; + final String? channelLayout; + final int? sampleRate; + + /// Jellyfin `AudioSpatialFormat` (`DolbyAtmos`, `DTSX`). + final String? spatialFormat; + + // ---- subtitle ---- + final bool? isTextSubtitle; + + /// The server can hand this subtitle over as a sidecar file rather than + /// muxed in the stream. + final bool? supportsExternalStream; + + /// Sidecar subtitle file format (Plex `format`). + final String? subtitleFormat; + + /// Where a downloaded subtitle came from (Plex `providerTitle`). + final String? providerTitle; + + /// Match score of a downloaded/auto-selected subtitle. + final int? score; + + /// Plex `key` on a sidecar subtitle — the server-relative path the + /// subtitle file is served from, and what marks the track external. + final String? externalKey; + + /// Plex `transient` — a subtitle attached to this playback only, not + /// stored on the item. + final bool? isTransient; + + /// Plex `sourceKey` — the library stream a downloaded subtitle was + /// copied from. + final String? sourceKey; + + const MediaStreamDetails({ + required this.kind, + required this.ordinal, + this.timeBase, + this.externalKey, + this.isTransient, + this.sourceKey, + this.comment, + this.rotation, + this.hasDescriptions, + this.headerCompression, + this.filePath, + this.id, + this.index, + this.title, + this.displayTitle, + this.codec, + this.codecTag, + this.profile, + this.level, + this.language, + this.languageCode, + this.bitrateKbps, + this.isDefault, + this.isForced, + this.isSelected, + this.isExternal, + this.isHearingImpaired, + this.isDub, + this.isOriginal, + this.width, + this.height, + this.codedWidth, + this.codedHeight, + this.frameRate, + this.scanType, + this.isInterlaced, + this.bitDepth, + this.refFrames, + this.pixelFormat, + this.colorSpace, + this.colorRange, + this.colorPrimaries, + this.colorTransfer, + this.chromaSubsampling, + this.chromaLocation, + this.aspectRatio, + this.pixelAspectRatio, + this.isAnamorphic, + this.videoRange = MediaVideoRange.unknown, + this.hasHdr10Plus, + this.dolbyVision, + this.isAvc, + this.nalLengthSize, + this.hasScalingMatrix, + this.streamIdentifier, + this.channels, + this.channelLayout, + this.sampleRate, + this.spatialFormat, + this.isTextSubtitle, + this.supportsExternalStream, + this.subtitleFormat, + this.providerTitle, + this.score, + }); + + String? get bitrateFormatted => bitrateKbps == null ? null : ByteFormatter.formatBitrate(bitrateKbps!); + + String? get resolutionFormatted => (width != null && height != null) ? '${width}x$height' : null; + + String? get codedResolutionFormatted => + (codedWidth != null && codedHeight != null) ? '${codedWidth}x$codedHeight' : null; + String? get frameRateFormatted { - if (frameRate != null) return '${frameRate!.toStringAsFixed(3)} fps'; - if (videoFrameRate != null) return videoFrameRate; + final rate = frameRate; + if (rate == null) return null; + final rounded = rate.roundToDouble(); + final text = (rate - rounded).abs() < 0.0005 ? rounded.toStringAsFixed(0) : rate.toStringAsFixed(3); + return '$text fps'; + } + + String? get sampleRateFormatted { + final rate = sampleRate; + if (rate == null) return null; + return rate % 1000 == 0 ? '${rate ~/ 1000} kHz' : '${(rate / 1000).toStringAsFixed(1)} kHz'; + } + + String? get bitDepthFormatted => bitDepth == null ? null : '$bitDepth bit'; + + /// `5.1 (6 ch)` — layout first, because that is what people scan for. + String? get channelsFormatted { + final layout = channelLayout; + final count = channels; + if (layout != null && layout.isNotEmpty && count != null) return '$layout ($count ch)'; + if (layout != null && layout.isNotEmpty) return layout; + if (count != null) return '$count ch'; return null; } - String? get audioChannelsFormatted { - if (audioChannels == null) return null; - var channelText = '$audioChannels channel${audioChannels! > 1 ? 's' : ''}'; - if (audioChannelLayout != null) channelText += ' ($audioChannelLayout)'; - return channelText; + bool get isSpatialAudio { + final format = spatialFormat; + return format != null && format.isNotEmpty && format.toLowerCase() != 'none'; + } + + /// `DolbyAtmos` → `Dolby Atmos`. Jellyfin ships the enum name verbatim. + String? get spatialFormatLabel { + if (!isSpatialAudio) return null; + return spatialFormat!.replaceAllMapped(RegExp(r'(?<=[a-z0-9])(?=[A-Z])'), (_) => ' '); + } + + /// Short label for the stream row: the server's own summary when it has one, + /// otherwise title/language/codec in that order. + String get headline { + for (final candidate in [displayTitle, title, language, codec]) { + if (candidate != null && candidate.trim().isNotEmpty) return candidate.trim(); + } + return '#$ordinal'; } } + +/// `1h 23m 45s` / `23m 45s` / `45s`. Shared by version and part rows. +String? formatMediaDuration(int? milliseconds) { + if (milliseconds == null) return null; + final totalSeconds = milliseconds ~/ 1000; + final hours = totalSeconds ~/ 3600; + final minutes = (totalSeconds % 3600) ~/ 60; + final seconds = totalSeconds % 60; + if (hours > 0) return '${hours}h ${minutes}m ${seconds}s'; + if (minutes > 0) return '${minutes}m ${seconds}s'; + return '${seconds}s'; +} diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 940dd4d2..e2596e7a 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -771,6 +771,10 @@ mixin MediaServerCacheMixin implements MediaServerClient { /// /// Returns `null` when offline mode is on and no cached row exists, or /// when both network and cache come up empty. + /// + /// Pass [cacheScope] when the caller already snapshotted a request context + /// (see [fetchWithCacheFirst]); otherwise the live profile is sampled, which + /// is only safe when nothing has awaited since the call began. Future fetchWithCacheFallback({ required String cacheKey, required Future Function() networkCall, @@ -778,10 +782,11 @@ mixin MediaServerCacheMixin implements MediaServerClient { required T? Function(MediaServerResponse response) parseResponse, bool Function(Object error)? shouldFallback, bool cacheResponse = true, + ServerId? cacheScope, }) async { - final cacheScope = ServerId(cacheServerId); + final scope = cacheScope ?? ServerId(cacheServerId); if (isOfflineMode) { - final cached = await cache.get(cacheScope, cacheKey); + final cached = await cache.get(scope, cacheKey); if (cached != null) return parseCache(cached); return null; } @@ -790,13 +795,13 @@ mixin MediaServerCacheMixin implements MediaServerClient { throwIfHttpError(response); final parsed = parseResponse(response); if (cacheResponse) { - await _putCacheResponse(cacheScope, cacheKey, response.data); + await _putCacheResponse(scope, cacheKey, response.data); } return parsed; } catch (e) { if (shouldFallback != null && !shouldFallback(e)) rethrow; appLogger.w('Network request failed for $cacheKey, trying cache', error: e); - final cached = await cache.get(cacheScope, cacheKey); + final cached = await cache.get(scope, cacheKey); if (cached != null) return parseCache(cached); rethrow; } diff --git a/lib/services/file_info_parser.dart b/lib/services/file_info_parser.dart index 6ce6186e..8c7e5e2a 100644 --- a/lib/services/file_info_parser.dart +++ b/lib/services/file_info_parser.dart @@ -1,4 +1,6 @@ +import '../media/media_file_info.dart'; import '../media/media_source_info.dart'; +import '../media/media_version.dart' show bitrateKbpsFromBps; import '../utils/json_utils.dart'; import 'plex_constants.dart'; @@ -240,3 +242,260 @@ class JellyfinFileInfoStreamReader implements FileInfoStreamReader { ); } } + +// --------------------------------------------------------------------------- +// Full per-stream detail extraction for the file-info sheet. +// +// [walkStreams] above deliberately keeps only what playback needs. The sheet +// needs every probed property, so these builders read the raw stream maps a +// second time and project them onto the neutral [MediaStreamDetails]. Both +// backends' field-name maps live side by side here for the same reason the +// readers above do: one place to compare when a server adds a key. +// --------------------------------------------------------------------------- + +/// Classify a Plex `Part.Stream[]` entry. Plex numbers stream types 1–5; +/// lyrics/other types beyond that map to [MediaStreamKind.unknown]. +MediaStreamKind plexStreamKind(Map s) => switch (flexibleInt(s['streamType'])) { + PlexStreamType.video => MediaStreamKind.video, + PlexStreamType.audio => MediaStreamKind.audio, + PlexStreamType.subtitle => MediaStreamKind.subtitle, + 4 => MediaStreamKind.image, + 5 => MediaStreamKind.data, + _ => MediaStreamKind.unknown, +}; + +/// Classify a Jellyfin `MediaStreams[]` entry. +MediaStreamKind jellyfinStreamKind(Map s) => switch ((s['Type'] as String?)?.toLowerCase()) { + 'video' => MediaStreamKind.video, + 'audio' => MediaStreamKind.audio, + 'subtitle' => MediaStreamKind.subtitle, + 'embeddedimage' => MediaStreamKind.image, + 'lyric' => MediaStreamKind.lyric, + 'data' => MediaStreamKind.data, + _ => MediaStreamKind.unknown, +}; + +/// Project a Plex `Part.Stream[]` entry. [ordinal] is the 1-based position +/// among streams of the same kind. +MediaStreamDetails plexStreamDetails(Map s, int ordinal) { + final kind = plexStreamKind(s); + final dolbyVision = _plexDolbyVision(s); + return MediaStreamDetails( + kind: kind, + ordinal: ordinal, + id: s['id']?.toString(), + index: flexibleInt(s['index']), + title: _text(s['title']), + displayTitle: _text(s['extendedDisplayTitle']) ?? _text(s['displayTitle']), + codec: _text(s['codec']), + codecTag: _text(s['codecID']), + profile: _text(s['profile']), + level: flexibleInt(s['level']), + language: _text(s['language']), + languageCode: _text(s['languageCode']) ?? _text(s['languageTag']), + // Plex already reports stream bitrates in kbps. + bitrateKbps: flexibleInt(s['bitrate']), + isDefault: flexibleBoolNullable(s['default']), + isForced: flexibleBoolNullable(s['forced']), + isSelected: flexibleBoolNullable(s['selected']), + isExternal: _plexIsExternal(s), + isHearingImpaired: flexibleBoolNullable(s['hearingImpaired']), + isDub: flexibleBoolNullable(s['dub']), + isOriginal: flexibleBoolNullable(s['original']), + width: flexibleInt(s['width']), + height: flexibleInt(s['height']), + codedWidth: flexibleInt(s['codedWidth']), + codedHeight: flexibleInt(s['codedHeight']), + frameRate: flexibleDouble(s['frameRate']), + scanType: _text(s['scanType']), + isInterlaced: _plexInterlaced(s), + bitDepth: flexibleInt(s['bitDepth']), + refFrames: flexibleInt(s['refFrames']), + colorSpace: _text(s['colorSpace']), + colorRange: _text(s['colorRange']), + colorPrimaries: _text(s['colorPrimaries']), + colorTransfer: _text(s['colorTrc']), + chromaSubsampling: _text(s['chromaSubsampling']), + chromaLocation: _text(s['chromaLocation']), + pixelAspectRatio: _text(s['pixelAspectRatio']), + isAnamorphic: flexibleBoolNullable(s['anamorphic']), + videoRange: kind == MediaStreamKind.video ? _plexVideoRange(s, dolbyVision) : MediaVideoRange.unknown, + dolbyVision: dolbyVision, + hasScalingMatrix: flexibleBoolNullable(s['hasScalingMatrix']), + streamIdentifier: _text(s['streamIdentifier']), + channels: flexibleInt(s['channels']), + channelLayout: _text(s['audioChannelLayout']), + sampleRate: flexibleInt(s['samplingRate']), + subtitleFormat: _text(s['format']), + providerTitle: _text(s['providerTitle']), + score: flexibleInt(s['score']), + externalKey: _text(s['key']), + isTransient: flexibleBoolNullable(s['transient']), + sourceKey: _text(s['sourceKey']), + comment: _text(s['comment']), + hasDescriptions: flexibleBoolNullable(s['descriptions']), + headerCompression: flexibleBoolNullable(s['headerCompression']), + ); +} + +/// Project a Jellyfin `MediaSources[].MediaStreams[]` entry. +MediaStreamDetails jellyfinStreamDetails(Map s, int ordinal) { + final kind = jellyfinStreamKind(s); + final interlaced = flexibleBoolNullable(s['IsInterlaced']); + return MediaStreamDetails( + kind: kind, + ordinal: ordinal, + index: flexibleInt(s['Index']), + title: _text(s['Title']), + displayTitle: _text(s['DisplayTitle']), + codec: _text(s['Codec']), + codecTag: _text(s['CodecTag']), + profile: _text(s['Profile']), + level: flexibleInt(s['Level']), + language: _text(s['DisplayLanguage']) ?? _text(s['Language']), + languageCode: _text(s['Language']), + bitrateKbps: bitrateKbpsFromBps(flexibleInt(s['BitRate'])), + isDefault: flexibleBoolNullable(s['IsDefault']), + isForced: flexibleBoolNullable(s['IsForced']), + isExternal: flexibleBoolNullable(s['IsExternal']), + isHearingImpaired: flexibleBoolNullable(s['IsHearingImpaired']), + width: flexibleInt(s['Width']), + height: flexibleInt(s['Height']), + frameRate: flexibleDouble(s['RealFrameRate']) ?? flexibleDouble(s['AverageFrameRate']), + scanType: interlaced == null ? null : (interlaced ? 'interlaced' : 'progressive'), + isInterlaced: interlaced, + bitDepth: flexibleInt(s['BitDepth']), + refFrames: flexibleInt(s['RefFrames']), + pixelFormat: _text(s['PixelFormat']), + colorSpace: _text(s['ColorSpace']), + colorRange: _text(s['ColorRange']), + colorPrimaries: _text(s['ColorPrimaries']), + colorTransfer: _text(s['ColorTransfer']), + aspectRatio: _text(s['AspectRatio']), + isAnamorphic: flexibleBoolNullable(s['IsAnamorphic']), + videoRange: kind == MediaStreamKind.video ? _jellyfinVideoRange(s) : MediaVideoRange.unknown, + hasHdr10Plus: flexibleBoolNullable(s['Hdr10PlusPresentFlag']), + dolbyVision: _jellyfinDolbyVision(s), + isAvc: flexibleBoolNullable(s['IsAVC']), + nalLengthSize: flexibleInt(s['NalLengthSize']), + channels: flexibleInt(s['Channels']), + channelLayout: _text(s['ChannelLayout']), + sampleRate: flexibleInt(s['SampleRate']), + spatialFormat: _text(s['AudioSpatialFormat']), + isTextSubtitle: flexibleBoolNullable(s['IsTextSubtitleStream']), + supportsExternalStream: flexibleBoolNullable(s['SupportsExternalStream']), + score: flexibleInt(s['Score']), + timeBase: _text(s['TimeBase']), + externalKey: _text(s['DeliveryUrl']), + comment: _text(s['Comment']), + rotation: flexibleInt(s['Rotation']), + filePath: _text(s['Path']), + ); +} + +/// Project a Jellyfin `MediaSources[].MediaAttachments[]` entry. Plex does not +/// expose attachments on `/library/metadata`, so this has no Plex sibling. +MediaFileAttachment jellyfinAttachment(Map a) { + return MediaFileAttachment( + index: flexibleInt(a['Index']), + fileName: _text(a['FileName']), + mimeType: _text(a['MimeType']), + // Live servers populate `CodecTag`; `Codec` is in the DTO but usually null. + codec: _text(a['Codec']) ?? _text(a['CodecTag']), + ); +} + +/// Trim and drop empties — both servers emit `""` for "not probed", and an +/// empty row is worse than an absent one. +String? _text(Object? value) { + if (value == null) return null; + final text = value.toString().trim(); + return text.isEmpty ? null : text; +} + +/// Plex marks sidecar subtitles with a `/library/streams/...` key rather than +/// an explicit flag. +bool? _plexIsExternal(Map s) { + if (flexibleInt(s['streamType']) != PlexStreamType.subtitle) return null; + final key = s['key']; + return key is String && key.isNotEmpty; +} + +bool? _plexInterlaced(Map s) { + final scanType = _text(s['scanType'])?.toLowerCase(); + return scanType == null ? null : scanType != 'progressive'; +} + +MediaDolbyVisionInfo? _plexDolbyVision(Map s) { + if (!flexibleBool(s['DOVIPresent'])) return null; + return MediaDolbyVisionInfo( + profile: flexibleInt(s['DOVIProfile']), + level: flexibleInt(s['DOVILevel']), + version: _text(s['DOVIVersion']), + blCompatibilityId: flexibleInt(s['DOVIBLCompatID']), + blPresent: flexibleBoolNullable(s['DOVIBLPresent']), + elPresent: flexibleBoolNullable(s['DOVIELPresent']), + rpuPresent: flexibleBoolNullable(s['DOVIRPUPresent']), + ); +} + +MediaDolbyVisionInfo? _jellyfinDolbyVision(Map s) { + final major = flexibleInt(s['DvVersionMajor']); + final minor = flexibleInt(s['DvVersionMinor']); + final info = MediaDolbyVisionInfo( + profile: flexibleInt(s['DvProfile']), + level: flexibleInt(s['DvLevel']), + version: major == null ? null : '$major.${minor ?? 0}', + blCompatibilityId: flexibleInt(s['DvBlSignalCompatibilityId']), + blPresent: flexibleBoolNullable(s['BlPresentFlag']), + elPresent: flexibleBoolNullable(s['ElPresentFlag']), + rpuPresent: flexibleBoolNullable(s['RpuPresentFlag']), + title: _text(s['VideoDoViTitle']), + ); + return info.isEmpty ? null : info; +} + +/// Plex reports no dynamic-range field, so classify from the Dolby Vision +/// block first and fall back to the transfer characteristics. +MediaVideoRange _plexVideoRange(Map s, MediaDolbyVisionInfo? dolbyVision) { + if (dolbyVision != null) { + // `dv_bl_signal_compatibility_id`: 1 = HDR10, 2 = SDR/BT.709, 4 = HLG, + // 6 = Blu-ray HDR10. Profile 7 always carries an HDR10 base layer, and + // profile 5 carries none (0), so it stays plain Dolby Vision. + final compatibility = dolbyVision.blCompatibilityId; + if (compatibility == 4) return MediaVideoRange.dolbyVisionHlg; + if (dolbyVision.profile == 7 || compatibility == 1 || compatibility == 6) { + return MediaVideoRange.dolbyVisionHdr10; + } + return MediaVideoRange.dolbyVision; + } + return switch (_text(s['colorTrc'])?.toLowerCase()) { + 'smpte2084' => MediaVideoRange.hdr10, + 'arib-std-b67' => MediaVideoRange.hlg, + null => MediaVideoRange.unknown, + _ => MediaVideoRange.sdr, + }; +} + +MediaVideoRange _jellyfinVideoRange(Map s) { + final type = _text(s['VideoRangeType'])?.toLowerCase(); + if (type != null && type != 'unknown') { + if (type.startsWith('dovi')) { + if (type.contains('hlg')) return MediaVideoRange.dolbyVisionHlg; + if (type.contains('hdr10')) return MediaVideoRange.dolbyVisionHdr10; + return MediaVideoRange.dolbyVision; + } + return switch (type) { + 'sdr' => MediaVideoRange.sdr, + 'hdr10' => MediaVideoRange.hdr10, + 'hdr10plus' => MediaVideoRange.hdr10Plus, + 'hlg' => MediaVideoRange.hlg, + _ => MediaVideoRange.hdr, + }; + } + return switch (_text(s['VideoRange'])?.toLowerCase()) { + 'sdr' => MediaVideoRange.sdr, + 'hdr' => MediaVideoRange.hdr, + _ => MediaVideoRange.unknown, + }; +} diff --git a/lib/services/jellyfin_client/parts/file_info.dart b/lib/services/jellyfin_client/parts/file_info.dart index 77d738b5..57e01fa6 100644 --- a/lib/services/jellyfin_client/parts/file_info.dart +++ b/lib/services/jellyfin_client/parts/file_info.dart @@ -12,65 +12,139 @@ mixin _JellyfinFileInfoMethods on _JellyfinClientInternals { itemJson = fresh?.raw is Map ? fresh!.raw as Map : null; } if (itemJson == null) return null; - return _buildFileInfoFromJellyfinItem(itemJson); - } - - MediaFileInfo? _buildFileInfoFromJellyfinItem(Map json) { - final sources = json['MediaSources']; - if (sources is! List || sources.isEmpty) return null; - final source = sources.first; - if (source is! Map) return null; - - final parsed = walkStreams(source['MediaStreams'] as List?, const JellyfinFileInfoStreamReader()); - final videoStream = parsed.videoStream; - final audioStream = parsed.audioStream; - final audioTracks = parsed.audioTracks; - final subtitleTracks = parsed.subtitleTracks; - - final width = flexibleInt(videoStream?['Width']); - final height = flexibleInt(videoStream?['Height']); - final aspectRatioString = videoStream?['AspectRatio'] as String?; - double? aspectRatio; - if (aspectRatioString != null && aspectRatioString.contains(':')) { - final parts = aspectRatioString.split(':'); - final num = flexibleDouble(parts.first); - final den = flexibleDouble(parts[1]); - if (num != null && den != null && den != 0) aspectRatio = num / den; - } - aspectRatio ??= (width != null && height != null && height != 0) ? width / height : null; - - final durationMs = jellyfinTicksToMs(flexibleInt(source['RunTimeTicks'])); - - final bitrateBps = flexibleInt(source['Bitrate']); - final videoBitrateBps = flexibleInt(videoStream?['BitRate']); - - return MediaFileInfo( - container: source['Container'] as String?, - videoCodec: videoStream?['Codec'] as String?, - videoResolution: resolutionLabelFromDimensions(width, height), - videoFrameRate: videoStream?['RealFrameRate']?.toString() ?? videoStream?['AverageFrameRate']?.toString(), - videoProfile: videoStream?['Profile'] as String?, - width: width, - height: height, - aspectRatio: aspectRatio, - // Plex stores bitrate as kbps; Jellyfin returns bps. Normalise to kbps. - bitrate: bitrateKbpsFromBps(bitrateBps), - duration: durationMs, - audioCodec: audioStream?['Codec'] as String?, - audioProfile: audioStream?['Profile'] as String?, - audioChannels: flexibleInt(audioStream?['Channels']), - filePath: source['Path'] as String?, - fileSize: flexibleInt(source['Size']), - colorSpace: videoStream?['ColorSpace'] as String?, - colorRange: videoStream?['ColorRange'] as String?, - colorPrimaries: videoStream?['ColorPrimaries'] as String?, - chromaSubsampling: null, - frameRate: flexibleDouble(videoStream?['RealFrameRate']) ?? flexibleDouble(videoStream?['AverageFrameRate']), - bitDepth: flexibleInt(videoStream?['BitDepth']), - videoBitrate: bitrateKbpsFromBps(videoBitrateBps), - audioChannelLayout: audioStream?['ChannelLayout'] as String?, - audioTracks: audioTracks, - subtitleTracks: subtitleTracks, - ); + return parseJellyfinFileInfoFromJson(itemJson); } } + +/// Map every `MediaSources` entry of a Jellyfin item response. Jellyfin folds +/// the file into the source, so each version yields exactly one part. +/// +/// Pure sibling of [parsePlexFileInfoFromJson]; kept top-level so the mapping +/// is testable without a live client. +MediaFileInfo? parseJellyfinFileInfoFromJson(Map json) { + final sources = json['MediaSources']; + if (sources is! List || sources.isEmpty) return null; + + final versions = []; + for (final source in sources) { + if (source is! Map) continue; + // One unusable source must not cost the user the others. + try { + versions.add(_jellyfinFileVersion(source)); + } catch (error, stackTrace) { + appLogger.w('Skipping malformed Jellyfin media source', error: error, stackTrace: stackTrace); + } + } + return versions.isEmpty ? null : MediaFileInfo(versions: versions); +} + +MediaStreamDetails? _firstStreamOfKind(List streams, MediaStreamKind kind) { + for (final stream in streams) { + if (stream.kind == kind) return stream; + } + return null; +} + +MediaFileVersion _jellyfinFileVersion(Map source) { + final streams = _jellyfinStreamDetailList(source['MediaStreams']); + final video = _firstStreamOfKind(streams, MediaStreamKind.video); + final audio = _firstStreamOfKind(streams, MediaStreamKind.audio); + + final width = video?.width; + final height = video?.height; + final durationMs = jellyfinTicksToMs(flexibleInt(source['RunTimeTicks'])); + + return MediaFileVersion( + id: _jellyfinText(source['Id']), + title: _jellyfinText(source['Name']), + container: _jellyfinText(source['Container']), + // Jellyfin reports bitrates in bps; the app standard is kbps. + bitrateKbps: bitrateKbpsFromBps(flexibleInt(source['Bitrate'])), + durationMs: durationMs, + width: width, + height: height, + aspectRatio: _jellyfinAspectRatio(video?.aspectRatio, width, height), + videoResolutionLabel: resolutionLabelFromDimensions(width, height), + videoCodec: video?.codec, + videoProfile: video?.profile, + videoFrameRateLabel: video?.frameRateFormatted, + audioCodec: audio?.codec, + audioProfile: audio?.profile, + audioChannels: audio?.channels, + protocol: _jellyfinText(source['Protocol']), + videoType: _jellyfinText(source['VideoType']), + sourceType: _jellyfinText(source['Type']), + isRemote: flexibleBoolNullable(source['IsRemote']), + isInfiniteStream: flexibleBoolNullable(source['IsInfiniteStream']), + supportsDirectPlay: flexibleBoolNullable(source['SupportsDirectPlay']), + supportsDirectStream: flexibleBoolNullable(source['SupportsDirectStream']), + supportsTranscoding: flexibleBoolNullable(source['SupportsTranscoding']), + eTag: _jellyfinText(source['ETag']), + transportStreamTimestamp: _jellyfinText(source['Timestamp']), + defaultAudioStreamIndex: flexibleInt(source['DefaultAudioStreamIndex']), + defaultSubtitleStreamIndex: flexibleInt(source['DefaultSubtitleStreamIndex']), + parts: [ + MediaFilePart( + id: _jellyfinText(source['Id']), + filePath: _jellyfinText(source['Path']), + fileSize: flexibleInt(source['Size']), + container: _jellyfinText(source['Container']), + durationMs: durationMs, + streams: streams, + ), + ], + attachments: _jellyfinAttachmentList(source['MediaAttachments']), + ); +} + +/// Prefer the server's declared display ratio (`2.35:1`), falling back to the +/// probed dimensions. +double? _jellyfinAspectRatio(String? declared, int? width, int? height) { + if (declared != null && declared.contains(':')) { + final parts = declared.split(':'); + final numerator = flexibleDouble(parts.first); + final denominator = flexibleDouble(parts[1]); + if (numerator != null && denominator != null && denominator != 0) return numerator / denominator; + } + if (width != null && height != null && height != 0) return width / height; + return null; +} + +/// Trim and drop empties; tolerate a server that sends a non-string scalar. +String? _jellyfinText(Object? value) { + if (value == null) return null; + final text = value.toString().trim(); + return text.isEmpty ? null : text; +} + +List _jellyfinStreamDetailList(Object? rawStreams) { + if (rawStreams is! List) return const []; + final streams = []; + final ordinals = {}; + for (final raw in rawStreams) { + if (raw is! Map) continue; + try { + final kind = jellyfinStreamKind(raw); + final ordinal = (ordinals[kind] ?? 0) + 1; + ordinals[kind] = ordinal; + streams.add(jellyfinStreamDetails(raw, ordinal)); + } catch (error, stackTrace) { + appLogger.w('Skipping malformed Jellyfin stream metadata', error: error, stackTrace: stackTrace); + } + } + return streams; +} + +List _jellyfinAttachmentList(Object? rawAttachments) { + if (rawAttachments is! List) return const []; + final attachments = []; + for (final raw in rawAttachments) { + if (raw is! Map) continue; + try { + attachments.add(jellyfinAttachment(raw)); + } catch (error, stackTrace) { + appLogger.w('Skipping malformed Jellyfin attachment metadata', error: error, stackTrace: stackTrace); + } + } + return attachments; +} diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index d5dc8fd4..3bf7b1e3 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1814,7 +1814,20 @@ class PlexClient Future _fetchFileInfo(String ratingKey) async { try { - final metadataJson = await _fetchRawMetadataJsonCacheFirst(ratingKey); + // One snapshot for both reads: the cache lookup yields before the + // refetch decision, and `applyProfileUpdate` may land in between. + // Sampling the live profile for the second read would cross identities. + final requestContext = _captureCacheFirstRequestContext(); + var metadataJson = await _fetchRawMetadataJsonCacheFirst(ratingKey, requestContext); + // The `/library/metadata/{id}` cache row is shared, and lighter writers + // (getPlaybackExtras) fill it from a request without `includeStreams` / + // `checkFiles`. Serving that row here would render a file-info sheet + // with no stream table and no presence flags, so re-fetch the full + // shape once. Offline, or when the refetch fails, the partial row is + // still better than nothing. + if (!_plexMetadataHasStreamDetail(metadataJson) && !isOfflineMode) { + metadataJson = await _refetchRawMetadataJson(ratingKey, requestContext) ?? metadataJson; + } return parsePlexFileInfoFromJson(metadataJson); } catch (e) { appLogger.e('Failed to get file info: $e'); @@ -1822,14 +1835,57 @@ class PlexClient } } + /// Whether a `/library/metadata` row was fetched with the file-info query + /// shape. + /// + /// Checks for the *keys* `includeStreams=1` and `checkFiles=1` add, on every + /// part: a fully probed part may legitimately report `Stream: []`, and one + /// populated sibling must not make a lean multi-part row look complete. A + /// row with no media at all needs no refetch — there is nothing to probe. + static bool _plexMetadataHasStreamDetail(Map? metadataJson) { + for (final media in flexibleMapList(metadataJson?['Media'])) { + for (final part in flexibleMapList(media['Part'])) { + if (!part.containsKey('Stream') || !part.containsKey('exists') || !part.containsKey('accessible')) { + return false; + } + } + } + return true; + } + + /// Network-first re-read of the full metadata shape, refreshing the shared + /// cache row so the next reader gets the complete payload too. Runs under + /// the caller's [requestContext] so the token that goes out and the cache + /// namespace that comes back belong to the same profile. + Future?> _refetchRawMetadataJson( + String ratingKey, + ({ServerId cacheScope, Map headers}) requestContext, + ) async { + final data = await fetchWithCacheFallback>( + cacheScope: requestContext.cacheScope, + cacheKey: '/library/metadata/$ratingKey', + networkCall: () => _http.get( + '/library/metadata/$ratingKey', + queryParameters: {'includeChapters': 1, 'includeMarkers': 1, 'checkFiles': 1, 'includeStreams': 1}, + headers: requestContext.headers, + ), + parseCache: (cached) => cached as Map?, + parseResponse: (response) => response.data as Map?, + ); + return _getFirstMetadataJsonFromData(data); + } + /// Cache-first raw metadata JSON for [ratingKey]. Serves the shared /// `/library/metadata/{id}` cache row when a detail/playback flow already /// warmed it (the common case — no extra round-trip); on a miss it fetches /// with the full playback query params so the row it caches stays complete /// for the cache-only readers ([fetchPlaybackExtrasFromCacheOnly], /// [fetchCachedMediaSourceInfo]). - Future?> _fetchRawMetadataJsonCacheFirst(String ratingKey) async { - final requestContext = _captureCacheFirstRequestContext(); + Future?> _fetchRawMetadataJsonCacheFirst( + String ratingKey, [ + ({ServerId cacheScope, Map headers})? context, + ]) async { + final requestContext = context ?? _captureCacheFirstRequestContext(); final data = await fetchWithCacheFirst>( cacheScope: requestContext.cacheScope, cacheKey: '/library/metadata/$ratingKey', diff --git a/lib/services/plex_playback_mapper.dart b/lib/services/plex_playback_mapper.dart index 30156b04..d68bb4f6 100644 --- a/lib/services/plex_playback_mapper.dart +++ b/lib/services/plex_playback_mapper.dart @@ -154,57 +154,94 @@ PlexVideoPlaybackData parsePlexVideoPlaybackDataFromJson( ); } +/// Build the file-info payload from a `/library/metadata/{id}` response. +/// +/// Every `Media` (version) and every `Part` (file) is mapped — split files and +/// multi-version libraries both produce more than one, and the sheet shows all +/// of them. MediaFileInfo? parsePlexFileInfoFromJson(Map? metadataJson) { final mediaList = _mapList(metadataJson?['Media']); - if (mediaList.isNotEmpty) { - final media = mediaList.first; - final partList = _mapList(media['Part']); - final version = PlexMappers.mediaVersionFromJson(Map.from(media)); - final partIndex = partList.isEmpty ? 0 : _firstPlayablePartIndex(version).clamp(0, partList.length - 1).toInt(); - final part = partList.isNotEmpty ? partList[partIndex] : null; + if (mediaList.isEmpty) return null; - // One pass over the streams array, capturing both the raw video / audio - // map pointers (for fields the parsed track classes don't carry — - // colorSpace, bitDepth, …) and the parsed track lists. - final parsedTracks = walkStreams(flexibleList(part?['Stream']), _streamReader, onMalformed: _logMalformedStream); - final videoStream = parsedTracks.videoStream; - final audioStream = parsedTracks.audioStream; + final versions = [ + for (final media in mediaList) _plexFileVersion(Map.from(media)), + ]; + return MediaFileInfo(versions: versions); +} - return MediaFileInfo( - // Media level properties - container: media['container'] as String?, - videoCodec: media['videoCodec'] as String?, - videoResolution: media['videoResolution'] as String?, - videoFrameRate: media['videoFrameRate'] as String?, - videoProfile: media['videoProfile'] as String?, - width: flexibleInt(media['width']), - height: flexibleInt(media['height']), - aspectRatio: flexibleDouble(media['aspectRatio']), - bitrate: flexibleInt(media['bitrate']), - duration: flexibleInt(media['duration']), - audioCodec: media['audioCodec'] as String?, - audioProfile: media['audioProfile'] as String?, - audioChannels: flexibleInt(media['audioChannels']), - optimizedForStreaming: flexibleBool(media['optimizedForStreaming']), - has64bitOffsets: flexibleBool(media['has64bitOffsets']), - // Part level properties (file) - filePath: part?['file'] as String?, - fileSize: flexibleInt(part?['size']), - // Video stream details - colorSpace: videoStream?['colorSpace'] as String?, - colorRange: videoStream?['colorRange'] as String?, - colorPrimaries: videoStream?['colorPrimaries'] as String?, - chromaSubsampling: videoStream?['chromaSubsampling'] as String?, - frameRate: flexibleDouble(videoStream?['frameRate']), - bitDepth: flexibleInt(videoStream?['bitDepth']), - videoBitrate: flexibleInt(videoStream?['bitrate']), - // Audio stream details - audioChannelLayout: audioStream?['audioChannelLayout'] as String?, - // All audio and subtitle tracks - audioTracks: parsedTracks.audioTracks, - subtitleTracks: parsedTracks.subtitleTracks, - ); +/// `Media.proxyType` value Plex uses for an optimized version. +const _plexOptimizedProxyType = 42; + +DateTime? _plexEpochSeconds(Object? value) { + final seconds = flexibleInt(value); + return seconds == null ? null : DateTime.fromMillisecondsSinceEpoch(seconds * 1000); +} + +MediaFileVersion _plexFileVersion(Map media) { + return MediaFileVersion( + id: media['id']?.toString(), + title: media['title'] as String?, + container: media['container'] as String?, + bitrateKbps: flexibleInt(media['bitrate']), + durationMs: flexibleInt(media['duration']), + width: flexibleInt(media['width']), + height: flexibleInt(media['height']), + aspectRatio: flexibleDouble(media['aspectRatio']), + videoResolutionLabel: media['videoResolution'] as String?, + videoCodec: media['videoCodec'] as String?, + videoProfile: media['videoProfile'] as String?, + videoFrameRateLabel: media['videoFrameRate'] as String?, + audioCodec: media['audioCodec'] as String?, + audioProfile: media['audioProfile'] as String?, + audioChannels: flexibleInt(media['audioChannels']), + optimizedForStreaming: flexibleBoolNullable(media['optimizedForStreaming']), + has64bitOffsets: flexibleBoolNullable(media['has64bitOffsets']), + // Plex marks a server-generated optimized copy with proxyType 42 and + // names the profile that produced it in `target`. + isOptimizedVersion: media['proxyType'] == null ? null : flexibleInt(media['proxyType']) == _plexOptimizedProxyType, + optimizationTarget: media['target'] as String?, + deletedAt: _plexEpochSeconds(media['deletedAt']), + displayOffsetPercent: flexibleInt(media['displayOffset']), + parts: [for (final part in _mapList(media['Part'])) _plexFilePart(Map.from(part))], + ); +} + +MediaFilePart _plexFilePart(Map part) { + return MediaFilePart( + id: part['id']?.toString(), + filePath: part['file'] as String?, + fileSize: flexibleInt(part['size']), + container: part['container'] as String?, + durationMs: flexibleInt(part['duration']), + optimizedForStreaming: flexibleBoolNullable(part['optimizedForStreaming']), + has64bitOffsets: flexibleBoolNullable(part['has64bitOffsets']), + hasThumbnail: flexibleBoolNullable(part['hasThumbnail']), + indexes: part['indexes'] as String?, + packetLength: flexibleInt(part['packetLength']), + previewFailureCode: flexibleInt(part['failureBIFResultCode']), + previewRetryCount: flexibleInt(part['failureBIFRetryCount']), + exists: flexibleBoolNullable(part['exists']), + accessible: flexibleBoolNullable(part['accessible']), + streamKey: part['key'] as String?, + streams: _plexStreamDetails(part['Stream']), + ); +} + +/// One malformed stream must not discard the rest of the table, so each entry +/// is projected independently. +List _plexStreamDetails(Object? rawStreams) { + final streams = []; + final ordinals = {}; + for (final raw in _mapList(rawStreams)) { + try { + final stream = Map.from(raw); + final kind = plexStreamKind(stream); + final ordinal = (ordinals[kind] ?? 0) + 1; + ordinals[kind] = ordinal; + streams.add(plexStreamDetails(stream, ordinal)); + } catch (error, stackTrace) { + _logMalformedStream(error, stackTrace, const {}); + } } - - return null; + return streams; } diff --git a/lib/widgets/file_info_bottom_sheet.dart b/lib/widgets/file_info_bottom_sheet.dart index 7b87953a..802663dd 100644 --- a/lib/widgets/file_info_bottom_sheet.dart +++ b/lib/widgets/file_info_bottom_sheet.dart @@ -1,10 +1,24 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../media/media_file_info.dart'; -import '../i18n/strings.g.dart'; -import '../utils/scroll_utils.dart'; -import 'bottom_sheet_header.dart'; +import '../focus/focusable_wrapper.dart'; +import '../i18n/strings.g.dart'; +import '../media/media_file_info.dart'; +import '../theme/mono_tokens.dart'; +import '../utils/formatters.dart'; +import '../utils/scroll_utils.dart'; +import '../utils/snackbar_helper.dart'; +import 'app_icon.dart'; +import 'bottom_sheet_header.dart'; +import 'stat_chip.dart'; + +/// Full technical breakdown of an item's files. +/// +/// Every version the server reports gets its own block, every file inside a +/// version its own card, and every demuxed stream a card of its own — nothing +/// is folded down to "the first one". Fields the active backend does not +/// report are skipped rather than rendered empty. class FileInfoBottomSheet extends StatefulWidget { final MediaFileInfo fileInfo; final String title; @@ -32,110 +46,660 @@ class _FileInfoBottomSheetState extends State { @override Widget build(BuildContext context) { - final info = widget.fileInfo; - final hasAdvanced = info.optimizedForStreaming != null || info.has64bitOffsets != null; + final versions = widget.fileInfo.versions; return Column( children: [ - BottomSheetHeader(title: t.fileInfo.title, icon: Symbols.info_rounded, closeFocusNode: _initialFocusNode), + BottomSheetHeader( + title: t.fileInfo.title, + icon: Symbols.info_rounded, + closeFocusNode: _initialFocusNode, + // Flat sheet: the tonal cards do the separating, so the header + // keeps no rule under it. + showBorder: false, + ), Expanded( child: ListView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), children: [ - if (widget.title.isNotEmpty) ...[ - Text(widget.title, style: const TextStyle(fontSize: 16, fontWeight: .w500)), - const SizedBox(height: 20), - ], - - _buildSectionHeader(t.fileInfo.video), - const SizedBox(height: 8), - if (info.videoCodec != null) _buildInfoRow(t.fileInfo.codec, info.videoCodec!), - if (info.resolutionFormatted != null) _buildInfoRow(t.fileInfo.resolution, info.resolutionFormatted!), - if (info.videoBitrateFormatted != null) _buildInfoRow(t.fileInfo.bitrate, info.videoBitrateFormatted!), - if (info.frameRateFormatted != null) _buildInfoRow(t.fileInfo.frameRate, info.frameRateFormatted!), - if (info.aspectRatioFormatted != null) _buildInfoRow(t.fileInfo.aspectRatio, info.aspectRatioFormatted!), - if (info.videoProfile != null) _buildInfoRow(t.fileInfo.profile, info.videoProfile!), - if (info.bitDepth != null) _buildInfoRow(t.fileInfo.bitDepth, '${info.bitDepth} bit'), - if (info.colorSpace != null) _buildInfoRow(t.fileInfo.colorSpace, info.colorSpace!), - if (info.colorRange != null) _buildInfoRow(t.fileInfo.colorRange, info.colorRange!), - if (info.colorPrimaries != null) _buildInfoRow(t.fileInfo.colorPrimaries, info.colorPrimaries!), - if (info.chromaSubsampling != null) _buildInfoRow(t.fileInfo.chromaSubsampling, info.chromaSubsampling!), - const SizedBox(height: 20), - - _buildSectionHeader(t.fileInfo.audio), - const SizedBox(height: 8), - if (info.audioTracks.isNotEmpty) - for (int i = 0; i < info.audioTracks.length; i++) - _buildInfoRow('${i + 1}', info.audioTracks[i].label.joined), - if (info.audioTracks.isEmpty) ...[ - if (info.audioCodec != null) _buildInfoRow(t.fileInfo.codec, info.audioCodec!), - if (info.audioChannelsFormatted != null) - _buildInfoRow(t.fileInfo.channels, info.audioChannelsFormatted!), - if (info.audioProfile != null) _buildInfoRow(t.fileInfo.profile, info.audioProfile!), - ], - const SizedBox(height: 20), - - if (info.subtitleTracks.isNotEmpty) ...[ - _buildSectionHeader(t.fileInfo.subtitles), - const SizedBox(height: 8), - for (int i = 0; i < info.subtitleTracks.length; i++) - _buildInfoRow('${i + 1}', info.subtitleTracks[i].label.joined), - const SizedBox(height: 20), - ], - - _buildSectionHeader(t.fileInfo.file), - const SizedBox(height: 8), - if (info.filePath != null) _buildInfoRow(t.fileInfo.path, info.filePath!, isMonospace: true), - if (info.fileSizeFormatted != null) _buildInfoRow(t.fileInfo.size, info.fileSizeFormatted!), - if (info.container != null) _buildInfoRow(t.fileInfo.container, info.container!), - if (info.durationFormatted != null) _buildInfoRow(t.fileInfo.duration, info.durationFormatted!), - if (info.bitrateFormatted != null) _buildInfoRow(t.fileInfo.overallBitrate, info.bitrateFormatted!), - if (hasAdvanced) ...[ - const SizedBox(height: 20), - - _buildSectionHeader(t.fileInfo.advanced), - const SizedBox(height: 8), - if (info.optimizedForStreaming != null) - _buildInfoRow( - t.fileInfo.optimizedForStreaming, - info.optimizedForStreaming! ? t.common.yes : t.common.no, - ), - if (info.has64bitOffsets != null) - _buildInfoRow(t.fileInfo.has64bitOffsets, info.has64bitOffsets! ? t.common.yes : t.common.no), - ], + if (widget.title.isNotEmpty) _ItemHeadline(title: widget.title, versions: versions), + for (var index = 0; index < versions.length; index++) + _VersionBlock( + version: versions[index], + index: index, + versionCount: versions.length, + isLast: index == versions.length - 1, + ), ], ), ), ], ); } - - Widget _buildSectionHeader(String title) { - return Text(title, style: const TextStyle(fontSize: 18, fontWeight: .bold)); - } - - Widget _buildInfoRow(String label, String value, {bool isMonospace = false}) { - return _FocusableInfoRow(label: label, value: value, isMonospace: isMonospace); - } } -class _FocusableInfoRow extends StatefulWidget { - final String label; - final String value; - final bool isMonospace; +/// Item title plus the at-a-glance chips people actually came for. +class _ItemHeadline extends StatelessWidget { + final String title; + final List versions; - const _FocusableInfoRow({required this.label, required this.value, this.isMonospace = false}); + const _ItemHeadline({required this.title, required this.versions}); @override - State<_FocusableInfoRow> createState() => _FocusableInfoRowState(); + Widget build(BuildContext context) { + final theme = Theme.of(context); + final version = versions.isEmpty ? null : versions.first; + final chips = version == null ? const [] : _summaryChips(version); + return Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)), + if (chips.isNotEmpty) ...[ + const SizedBox(height: 10), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final chip in chips) + StatChip(label: chip, backgroundColor: tokens(context).text.withValues(alpha: 0.08)), + ], + ), + ], + ], + ), + ); + } + + /// Container, resolution, dynamic range, codecs and size — the summary a + /// person scans before reading any table. + List _summaryChips(MediaFileVersion version) { + final video = _firstOfKind(version, MediaStreamKind.video); + final audio = _firstOfKind(version, MediaStreamKind.audio); + final chips = [ + if (version.container != null) version.container!.toUpperCase(), + if (version.resolutionFormatted != null) version.resolutionFormatted!, + if (video?.videoRange.label != null && video!.videoRange != MediaVideoRange.sdr) video.videoRange.label!, + if (version.videoCodec != null) version.videoCodec!.toUpperCase(), + if (audio?.codec != null) audio!.codec!.toUpperCase(), + if (audio?.spatialFormatLabel != null) audio!.spatialFormatLabel!, + if (version.totalFileSizeFormatted != null) version.totalFileSizeFormatted!, + if (version.durationFormatted != null) version.durationFormatted!, + ]; + return chips; + } + + MediaStreamDetails? _firstOfKind(MediaFileVersion version, MediaStreamKind kind) { + for (final part in version.parts) { + for (final stream in part.streams) { + if (stream.kind == kind) return stream; + } + } + return null; + } } -class _FocusableInfoRowState extends State<_FocusableInfoRow> { +/// One server-reported version: overview, its files, its streams. +class _VersionBlock extends StatelessWidget { + final MediaFileVersion version; + final int index; + final int versionCount; + final bool isLast; + + const _VersionBlock({required this.version, required this.index, required this.versionCount, required this.isLast}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (versionCount > 1) ...[ + Padding( + padding: EdgeInsets.only(top: index == 0 ? 0 : 8, bottom: 10), + child: Row( + children: [ + AppIcon(Symbols.layers_rounded, size: 18, fill: 1, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Text( + t.fileInfo.versionCounter(index: index + 1, count: versionCount), + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + if (version.title != null && version.title!.isNotEmpty) ...[ + const SizedBox(width: 8), + Flexible( + child: Text( + version.title!, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ), + ], + ], + ), + ), + ], + _InfoSection(title: t.fileInfo.overview, icon: Symbols.movie_info_rounded, fields: _overviewFields(version)), + for (var partIndex = 0; partIndex < version.parts.length; partIndex++) + _PartBlock(part: version.parts[partIndex], index: partIndex, partCount: version.parts.length), + if (version.attachments.isNotEmpty) + _InfoSection( + title: t.fileInfo.attachments, + icon: Symbols.attach_file_rounded, + subtitle: '${version.attachments.length}', + fields: [ + for (final attachment in version.attachments) + _InfoField( + label: attachment.fileName ?? '#${attachment.index ?? 0}', + value: [ + if (attachment.mimeType != null) attachment.mimeType!, + if (attachment.codec != null) attachment.codec!, + ].join(' · '), + ), + ], + ), + _InfoSection( + title: t.fileInfo.delivery, + icon: Symbols.cell_tower_rounded, + fields: _deliveryFields(context, version), + ), + if (!isLast) const SizedBox(height: 20), + ], + ); + } + + List<_InfoField?> _overviewFields(MediaFileVersion version) { + return [ + _InfoField.text(t.fileInfo.container, version.container?.toUpperCase()), + _InfoField.text(t.fileInfo.duration, version.durationFormatted), + _InfoField.text(t.fileInfo.totalSize, version.totalFileSizeFormatted), + _InfoField.text(t.fileInfo.overallBitrate, version.bitrateFormatted), + _InfoField.text(t.fileInfo.resolution, version.resolutionFormatted), + _InfoField.text(t.fileInfo.aspectRatio, version.aspectRatioFormatted), + _InfoField.text(t.fileInfo.video, _codecSummary(version.videoCodec, version.videoProfile)), + _InfoField.text(t.fileInfo.frameRate, version.videoFrameRateLabel), + _InfoField.text(t.fileInfo.audio, _codecSummary(version.audioCodec, version.audioProfile)), + _InfoField.text(t.fileInfo.channels, version.audioChannels == null ? null : '${version.audioChannels} ch'), + ]; + } + + String? _codecSummary(String? codec, String? profile) { + if (codec == null) return null; + return profile == null ? codec.toUpperCase() : '${codec.toUpperCase()} · $profile'; + } + + /// Everything about how the server can hand this version over, plus the + /// bookkeeping fields that only matter when something misbehaves. + List<_InfoField?> _deliveryFields(BuildContext context, MediaFileVersion version) { + return [ + _InfoField.text(t.fileInfo.protocol, version.protocol), + _InfoField.text(t.fileInfo.mediaType, version.videoType), + _InfoField.text(t.fileInfo.sourceKind, version.sourceType), + _InfoField.boolean(t.fileInfo.directPlay, version.supportsDirectPlay), + _InfoField.boolean(t.fileInfo.directStream, version.supportsDirectStream), + _InfoField.boolean(t.fileInfo.transcoding, version.supportsTranscoding), + _InfoField.boolean(t.fileInfo.optimizedForStreaming, version.optimizedForStreaming), + _InfoField.boolean(t.fileInfo.has64bitOffsets, version.has64bitOffsets), + _InfoField.boolean(t.fileInfo.optimizedVersion, version.isOptimizedVersion), + _InfoField.text(t.fileInfo.optimizationTarget, version.optimizationTarget), + _InfoField.text(t.fileInfo.deletedAt, _dateLabel(context, version.deletedAt)), + _InfoField.text(t.fileInfo.transportTimestamp, version.transportStreamTimestamp), + _InfoField.text( + t.fileInfo.displayOffset, + version.displayOffsetPercent == null ? null : '${version.displayOffsetPercent}%', + ), + _InfoField.boolean(t.fileInfo.remoteSource, version.isRemote), + _InfoField.boolean(t.fileInfo.infiniteStream, version.isInfiniteStream), + _InfoField.text(t.fileInfo.defaultAudioTrack, _streamIndexLabel(version.defaultAudioStreamIndex)), + _InfoField.text(t.fileInfo.defaultSubtitleTrack, _streamIndexLabel(version.defaultSubtitleStreamIndex)), + _InfoField.text(t.fileInfo.versionId, version.id, monospace: true), + _InfoField.text(t.fileInfo.etag, version.eTag, monospace: true), + ]; + } + + /// Jellyfin encodes "start with subtitles off" as index -1. + String? _streamIndexLabel(int? index) { + if (index == null) return null; + return index < 0 ? t.fileInfo.subtitlesOff : '#$index'; + } + + /// Locale-aware date plus a clock time that honours the system 24-hour + /// preference — an ISO string with the offset stripped would be ambiguous. + String? _dateLabel(BuildContext context, DateTime? value) { + if (value == null) return null; + final local = value.toLocal(); + final date = formatFullDate( + '${local.year.toString().padLeft(4, '0')}-' + '${local.month.toString().padLeft(2, '0')}-' + '${local.day.toString().padLeft(2, '0')}', + ); + final time = formatClockTime(local, is24Hour: MediaQuery.of(context).alwaysUse24HourFormat); + return '$date $time'; + } +} + +/// One file on disk plus the streams it carries. +class _PartBlock extends StatelessWidget { + final MediaFilePart part; + final int index; + final int partCount; + + const _PartBlock({required this.part, required this.index, required this.partCount}); + + /// Video first, then audio, subtitles, images, data — reading order. + static const _kindOrder = [ + MediaStreamKind.video, + MediaStreamKind.audio, + MediaStreamKind.subtitle, + MediaStreamKind.lyric, + MediaStreamKind.image, + MediaStreamKind.data, + MediaStreamKind.unknown, + ]; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InfoSection( + title: partCount > 1 ? t.fileInfo.fileCounter(index: index + 1, count: partCount) : t.fileInfo.file, + icon: Symbols.description_rounded, + fields: _fileFields(), + leading: part.filePath == null ? null : _PathRow(path: part.filePath!), + ), + for (final kind in _kindOrder) + if (part.streamsOfKind(kind).isNotEmpty) + _StreamGroup(kind: kind, streams: part.streamsOfKind(kind).toList(growable: false)), + if (part.streams.isEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + t.fileInfo.noStreams, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), + ], + ); + } + + List<_InfoField?> _fileFields() { + return [ + // Presence first: when a file has gone missing, that is the answer the + // sheet was opened for. + _InfoField.boolean(t.fileInfo.filePresent, part.exists), + _InfoField.boolean(t.fileInfo.fileReadable, part.accessible), + _InfoField.text(t.fileInfo.fileName, part.fileName), + _InfoField.text(t.fileInfo.size, part.fileSizeFormatted), + _InfoField.text(t.fileInfo.container, part.container?.toUpperCase()), + _InfoField.text(t.fileInfo.duration, part.durationFormatted), + _InfoField.boolean(t.fileInfo.optimizedForStreaming, part.optimizedForStreaming), + _InfoField.boolean(t.fileInfo.has64bitOffsets, part.has64bitOffsets), + _InfoField.boolean(t.fileInfo.previewThumbnails, part.hasThumbnail), + _InfoField.text(t.fileInfo.previewIndex, part.indexes), + _InfoField.text(t.fileInfo.packetLength, part.packetLength?.toString()), + _InfoField.text(t.fileInfo.previewFailureCode, part.previewFailureCode?.toString()), + _InfoField.text(t.fileInfo.previewRetries, part.previewRetryCount?.toString()), + _InfoField.text(t.fileInfo.streamPath, part.streamKey, monospace: true), + _InfoField.text(t.fileInfo.fileId, part.id, monospace: true), + ]; + } +} + +/// Full-width, monospace, copyable — a path is the one value people extract. +class _PathRow extends StatelessWidget { + final String path; + + const _PathRow({required this.path}); + + Future _copy(BuildContext context) async { + await Clipboard.setData(ClipboardData(text: path)); + if (!context.mounted) return; + showSuccessSnackBar(context, t.fileInfo.pathCopied); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return FocusableWrapper( + semanticLabel: t.fileInfo.copyPath, + useBackgroundFocus: true, + disableScale: true, + onSelect: () => _copy(context), + // GestureDetector, not InkWell: the sheet renders inside an + // OverlaySheetHost with no guarantee of an ink-capable Material, and + // FocusableWrapper already draws the focus affordance. + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _copy(context), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.fileInfo.path, + style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 2), + Text(path, style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace', height: 1.35)), + ], + ), + ), + const SizedBox(width: 8), + Tooltip( + message: t.fileInfo.copyPath, + child: AppIcon(Symbols.content_copy_rounded, size: 18, color: theme.colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ), + ); + } +} + +/// All streams of one kind, each as its own card. +class _StreamGroup extends StatelessWidget { + final MediaStreamKind kind; + final List streams; + + const _StreamGroup({required this.kind, required this.streams}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final stream in streams) + _InfoSection( + title: streams.length > 1 ? '${_groupTitle(kind)} ${stream.ordinal}' : _groupTitle(kind), + icon: _groupIcon(kind), + subtitle: stream.headline, + chips: _flagChips(stream), + fields: _streamFields(stream), + ), + ], + ); + } + + String _groupTitle(MediaStreamKind kind) => switch (kind) { + MediaStreamKind.video => t.fileInfo.video, + MediaStreamKind.audio => t.fileInfo.audio, + MediaStreamKind.subtitle => t.fileInfo.subtitles, + MediaStreamKind.image => t.fileInfo.images, + MediaStreamKind.lyric => t.fileInfo.lyrics, + MediaStreamKind.data || MediaStreamKind.unknown => t.fileInfo.dataStreams, + }; + + IconData _groupIcon(MediaStreamKind kind) => switch (kind) { + MediaStreamKind.video => Symbols.movie_rounded, + MediaStreamKind.audio => Symbols.graphic_eq_rounded, + MediaStreamKind.subtitle => Symbols.subtitles_rounded, + MediaStreamKind.image => Symbols.image_rounded, + MediaStreamKind.lyric => Symbols.lyrics_rounded, + MediaStreamKind.data || MediaStreamKind.unknown => Symbols.data_object_rounded, + }; + + List _flagChips(MediaStreamDetails stream) { + return [ + if (stream.isDefault ?? false) t.fileInfo.flagDefault, + if (stream.isSelected ?? false) t.fileInfo.flagSelected, + if (stream.isForced ?? false) t.fileInfo.flagForced, + if (stream.isExternal ?? false) t.fileInfo.flagExternal, + if (stream.isHearingImpaired ?? false) t.fileInfo.flagHearingImpaired, + if (stream.isDub ?? false) t.fileInfo.flagDub, + if (stream.isOriginal ?? false) t.fileInfo.flagOriginal, + ]; + } + + /// Common identity fields first, then the kind-specific block, then the + /// low-level container details. + List<_InfoField?> _streamFields(MediaStreamDetails stream) { + return [ + _InfoField.text(t.fileInfo.codec, stream.codec?.toUpperCase()), + _InfoField.text(t.fileInfo.profile, stream.profile), + // Level 0 is both servers' "not applicable" for audio/subtitle streams. + _InfoField.text(t.fileInfo.level, (stream.level ?? 0) > 0 ? stream.level!.toString() : null), + _InfoField.text(t.fileInfo.language, stream.language), + // Servers that cannot resolve a display name echo the ISO code into + // both fields; one row is enough then. + _InfoField.text(t.fileInfo.languageCode, _distinctLanguageCode(stream)), + _InfoField.text(t.fileInfo.streamTitle, stream.title), + _InfoField.text(t.fileInfo.bitrate, stream.bitrateFormatted), + if (kind == MediaStreamKind.video || kind == MediaStreamKind.image) ..._videoFields(stream), + if (kind == MediaStreamKind.audio) ..._audioFields(stream), + if (kind == MediaStreamKind.subtitle) ..._subtitleFields(stream), + _InfoField.text(t.fileInfo.codecTag, stream.codecTag), + _InfoField.text(t.fileInfo.timeBase, stream.timeBase), + _InfoField.text(t.fileInfo.streamIdentifier, stream.streamIdentifier), + _InfoField.text(t.fileInfo.streamIndex, stream.index?.toString()), + _InfoField.text(t.fileInfo.comment, stream.comment), + _InfoField.text(t.fileInfo.sidecarFile, stream.filePath, monospace: true), + _InfoField.text(t.fileInfo.streamId, stream.id, monospace: true), + ]; + } + + String? _distinctLanguageCode(MediaStreamDetails stream) { + final code = stream.languageCode; + if (code == null) return null; + return code.toLowerCase() == stream.language?.toLowerCase() ? null : code; + } + + List<_InfoField?> _videoFields(MediaStreamDetails stream) { + final dolbyVision = stream.dolbyVision; + return [ + _InfoField.text(t.fileInfo.resolution, stream.resolutionFormatted), + _InfoField.text(t.fileInfo.codedResolution, stream.codedResolutionFormatted), + _InfoField.text(t.fileInfo.aspectRatio, stream.aspectRatio), + _InfoField.text(t.fileInfo.pixelAspectRatio, stream.pixelAspectRatio), + _InfoField.text(t.fileInfo.frameRate, stream.frameRateFormatted), + _InfoField.text(t.fileInfo.rotation, stream.rotation == null ? null : '${stream.rotation}°'), + _InfoField.text(t.fileInfo.dynamicRange, _dynamicRange(stream)), + _InfoField.text(t.fileInfo.bitDepth, stream.bitDepthFormatted), + _InfoField.text(t.fileInfo.pixelFormat, stream.pixelFormat), + _InfoField.text(t.fileInfo.colorSpace, stream.colorSpace), + _InfoField.text(t.fileInfo.colorRange, stream.colorRange), + _InfoField.text(t.fileInfo.colorPrimaries, stream.colorPrimaries), + _InfoField.text(t.fileInfo.colorTransfer, stream.colorTransfer), + _InfoField.text(t.fileInfo.chromaSubsampling, stream.chromaSubsampling), + _InfoField.text(t.fileInfo.chromaLocation, stream.chromaLocation), + _InfoField.text(t.fileInfo.scanType, stream.scanType), + _InfoField.boolean(t.fileInfo.interlaced, stream.isInterlaced), + _InfoField.boolean(t.fileInfo.anamorphic, stream.isAnamorphic), + _InfoField.text(t.fileInfo.referenceFrames, stream.refFrames?.toString()), + if (dolbyVision != null) ...[ + _InfoField.text(t.fileInfo.dolbyVision, dolbyVision.profileFormatted), + _InfoField.text(t.fileInfo.dolbyVisionLevel, dolbyVision.level?.toString()), + _InfoField.text(t.fileInfo.dolbyVisionVersion, dolbyVision.version), + _InfoField.text(t.fileInfo.dolbyVisionLayers, _dolbyVisionLayers(dolbyVision)), + _InfoField.text(t.fileInfo.baseLayerCompatibility, dolbyVision.blCompatibilityId?.toString()), + ], + _InfoField.boolean(t.fileInfo.avcBitstream, stream.isAvc), + _InfoField.text(t.fileInfo.nalLengthSize, stream.nalLengthSize?.toString()), + _InfoField.boolean(t.fileInfo.scalingMatrix, stream.hasScalingMatrix), + ]; + } + + List<_InfoField?> _audioFields(MediaStreamDetails stream) { + return [ + _InfoField.text(t.fileInfo.channels, stream.channelsFormatted), + _InfoField.text(t.fileInfo.sampleRate, stream.sampleRateFormatted), + _InfoField.text(t.fileInfo.bitDepth, stream.bitDepthFormatted), + _InfoField.text(t.fileInfo.spatialAudio, stream.spatialFormatLabel), + ]; + } + + List<_InfoField?> _subtitleFields(MediaStreamDetails stream) { + return [ + _InfoField.boolean(t.fileInfo.textBased, stream.isTextSubtitle), + _InfoField.boolean(t.fileInfo.externalDelivery, stream.supportsExternalStream), + _InfoField.text(t.fileInfo.subtitleFormat, stream.subtitleFormat), + _InfoField.text(t.fileInfo.provider, stream.providerTitle), + _InfoField.text(t.fileInfo.matchScore, stream.score?.toString()), + _InfoField.boolean(t.fileInfo.audioDescription, stream.hasDescriptions), + _InfoField.boolean(t.fileInfo.headerCompression, stream.headerCompression), + _InfoField.boolean(t.fileInfo.temporary, stream.isTransient), + _InfoField.text(t.fileInfo.sourceStream, stream.sourceKey, monospace: true), + _InfoField.text(t.fileInfo.sidecarPath, stream.externalKey, monospace: true), + ]; + } + + /// `HDR10+` is an independent flag, so it is appended rather than replacing + /// the base classification. + String? _dynamicRange(MediaStreamDetails stream) { + final label = stream.videoRange.label; + final hasHdr10Plus = stream.hasHdr10Plus ?? false; + if (label == null) return hasHdr10Plus ? 'HDR10+' : null; + if (!hasHdr10Plus || stream.videoRange == MediaVideoRange.hdr10Plus) return label; + return '$label + HDR10+'; + } + + String? _dolbyVisionLayers(MediaDolbyVisionInfo info) { + final layers = [ + if (info.blPresent ?? false) 'BL', + if (info.elPresent ?? false) 'EL', + if (info.rpuPresent ?? false) 'RPU', + ]; + return layers.isEmpty ? null : layers.join(' + '); + } +} + +/// A titled card holding a responsive field grid. +class _InfoSection extends StatelessWidget { + final String title; + final IconData icon; + final String? subtitle; + final List chips; + final List<_InfoField?> fields; + + /// Rendered above the grid — used for the full-width path row. + final Widget? leading; + + const _InfoSection({ + required this.title, + required this.icon, + required this.fields, + this.subtitle, + this.chips = const [], + this.leading, + }); + + @override + Widget build(BuildContext context) { + final populated = fields.whereType<_InfoField>().toList(growable: false); + if (populated.isEmpty && leading == null) return const SizedBox.shrink(); + + final theme = Theme.of(context); + final mono = tokens(context); + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.fromLTRB(14, 12, 14, 14), + decoration: BoxDecoration( + // Flat: a tonal step off whatever the sheet is sitting on, no stroke. + // `bg` would collapse against the sheet on the OLED theme, where + // background and surface are one shade apart, so the fill is derived + // from the text colour the way the theme's own hover overlay is. + color: mono.text.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(mono.radiusLg), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + AppIcon(icon, size: 18, fill: 1, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Text(title, style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), + if (subtitle != null && subtitle!.isNotEmpty) ...[ + const SizedBox(width: 10), + Expanded( + child: Text( + subtitle!, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ), + ], + ], + ), + if (chips.isNotEmpty) ...[ + const SizedBox(height: 10), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final chip in chips) StatChip(label: chip, backgroundColor: mono.text.withValues(alpha: 0.1)), + ], + ), + ], + if (leading != null) ...[const SizedBox(height: 8), leading!], + if (populated.isNotEmpty) ...[const SizedBox(height: 4), _FieldGrid(fields: populated)], + ], + ), + ); + } +} + +/// Two columns when there is room, one when there is not. +class _FieldGrid extends StatelessWidget { + static const double _minColumnWidth = 220; + static const double _columnGap = 16; + + final List<_InfoField> fields; + + const _FieldGrid({required this.fields}); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final columns = width >= _minColumnWidth * 2 + _columnGap ? 2 : 1; + final cellWidth = columns == 1 ? width : (width - _columnGap) / 2; + return Wrap( + spacing: _columnGap, + children: [ + for (final field in fields) + SizedBox( + width: field.fullWidth ? width : cellWidth, + child: _FocusableField(field: field), + ), + ], + ); + }, + ); + } +} + +/// Label above value. Focusable so D-pad users can walk (and auto-scroll) +/// through a long table; there is nothing to activate. +class _FocusableField extends StatefulWidget { + final _InfoField field; + + const _FocusableField({required this.field}); + + @override + State<_FocusableField> createState() => _FocusableFieldState(); +} + +class _FocusableFieldState extends State<_FocusableField> { late final FocusNode _focusNode; @override void initState() { super.initState(); - _focusNode = FocusNode(); + _focusNode = FocusNode(debugLabel: 'FileInfoField(${widget.field.label})'); _focusNode.addListener(_onFocusChange); } @@ -147,32 +711,27 @@ class _FocusableInfoRowState extends State<_FocusableInfoRow> { } void _onFocusChange() { - if (_focusNode.hasFocus) { - scrollContextToCenter(context); - } + if (_focusNode.hasFocus) scrollContextToCenter(context); } @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final field = widget.field; return Focus( focusNode: _focusNode, child: Padding( padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - crossAxisAlignment: .start, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox( - width: 140, - child: Text( - widget.label, - style: TextStyle(color: Theme.of(context).textTheme.bodySmall?.color, fontSize: 14), - ), - ), - Expanded( - child: Text( - widget.value, - style: TextStyle(fontSize: 14, fontFamily: widget.isMonospace ? 'monospace' : null), - ), + Text(field.label, style: theme.textTheme.labelSmall?.copyWith(color: theme.colorScheme.onSurfaceVariant)), + const SizedBox(height: 2), + Text( + field.value, + style: field.monospace + ? theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace') + : theme.textTheme.bodyMedium, ), ], ), @@ -180,3 +739,25 @@ class _FocusableInfoRowState extends State<_FocusableInfoRow> { ); } } + +/// A populated label/value pair. The `text`/`boolean` factories return null for +/// absent data so the grids can be declared as flat lists. +class _InfoField { + final String label; + final String value; + final bool monospace; + final bool fullWidth; + + const _InfoField({required this.label, required this.value, this.monospace = false, this.fullWidth = false}); + + static _InfoField? text(String label, String? value, {bool monospace = false}) { + if (value == null || value.trim().isEmpty) return null; + final trimmed = value.trim(); + return _InfoField(label: label, value: trimmed, monospace: monospace, fullWidth: trimmed.length > 42); + } + + static _InfoField? boolean(String label, bool? value) { + if (value == null) return null; + return _InfoField(label: label, value: value ? t.common.yes : t.common.no); + } +} diff --git a/lib/widgets/stat_chip.dart b/lib/widgets/stat_chip.dart index 6d4c2ea2..7a39ac18 100644 --- a/lib/widgets/stat_chip.dart +++ b/lib/widgets/stat_chip.dart @@ -9,7 +9,12 @@ class StatChip extends StatelessWidget { final Color? iconColor; final String label; - const StatChip({super.key, this.icon, this.iconColor, required this.label}); + /// Overrides the default fill. Needed where the chip sits on a surface that + /// already uses `surfaceContainerHigh` — the mono theme collapses several + /// container roles onto one colour, so the default would be invisible. + final Color? backgroundColor; + + const StatChip({super.key, this.icon, this.iconColor, required this.label, this.backgroundColor}); @override Widget build(BuildContext context) { @@ -17,7 +22,7 @@ class StatChip extends StatelessWidget { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHigh, + color: backgroundColor ?? theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(999), ), child: Row( diff --git a/test/services/file_info_key_coverage_test.dart b/test/services/file_info_key_coverage_test.dart new file mode 100644 index 00000000..1b1d28bc --- /dev/null +++ b/test/services/file_info_key_coverage_test.dart @@ -0,0 +1,997 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_file_info.dart'; +import 'package:plezy/services/jellyfin_client.dart'; +import 'package:plezy/services/plex_playback_mapper.dart'; + +/// Guards the file-info sheet's promise that nothing the server reports is +/// silently dropped. +/// +/// The fixtures below enumerate every key observed on the production request +/// shapes — Plex `/library/metadata/{id}?includeChapters=1&includeMarkers=1` +/// `&checkFiles=1&includeStreams=1`, Jellyfin `/Users/{id}/Items/{id}` with +/// the client's detail field set. Each key must be accounted for exactly once: +/// carried onto the model, deliberately folded into another field, or listed +/// in an exclusion set with a reason. Adding a key to a fixture without +/// classifying it fails the suite. +void main() { + group('Plex key coverage', () { + test('every populated Media / Part / Stream key is classified', () { + final info = parsePlexFileInfoFromJson(_plexMetadata)!; + final version = info.versions.single; + final part = version.parts.single; + final video = part.streamsOfKind(MediaStreamKind.video).single; + final audio = part.streamsOfKind(MediaStreamKind.audio).single; + final subtitle = part.streamsOfKind(MediaStreamKind.subtitle).single; + + _expectClassified( + observed: (_plexMetadata['Media']! as List).first as Map, + carried: { + 'id': version.id, + 'title': version.title, + 'container': version.container, + 'bitrate': version.bitrateKbps, + 'duration': version.durationMs, + 'width': version.width, + 'height': version.height, + 'aspectRatio': version.aspectRatio, + 'videoResolution': version.videoResolutionLabel, + 'videoCodec': version.videoCodec, + 'videoProfile': version.videoProfile, + 'videoFrameRate': version.videoFrameRateLabel, + 'audioCodec': version.audioCodec, + 'audioProfile': version.audioProfile, + 'audioChannels': version.audioChannels, + 'optimizedForStreaming': version.optimizedForStreaming, + 'has64bitOffsets': version.has64bitOffsets, + 'proxyType': version.isOptimizedVersion, + 'target': version.optimizationTarget, + 'deletedAt': version.deletedAt, + 'displayOffset': version.displayOffsetPercent, + }, + nested: const {'Part'}, + excluded: const {}, + label: 'Plex Media', + ); + + _expectClassified( + observed: (_plexMedia['Part']! as List).first as Map, + carried: { + 'id': part.id, + 'key': part.streamKey, + 'file': part.filePath, + 'size': part.fileSize, + 'container': part.container, + 'duration': part.durationMs, + 'indexes': part.indexes, + 'hasThumbnail': part.hasThumbnail, + 'has64bitOffsets': part.has64bitOffsets, + 'optimizedForStreaming': part.optimizedForStreaming, + 'packetLength': part.packetLength, + 'exists': part.exists, + 'accessible': part.accessible, + 'failureBIFResultCode': part.previewFailureCode, + 'failureBIFRetryCount': part.previewRetryCount, + }, + nested: const {'Stream'}, + // Plex repeats the primary streams' profiles on the Part; the sheet + // shows them once on the version and once per stream. + excluded: const {'videoProfile', 'audioProfile'}, + label: 'Plex Part', + ); + + _expectClassified( + observed: _plexVideoStream, + carried: { + 'id': video.id, + 'index': video.index, + 'codec': video.codec, + 'codecID': video.codecTag, + 'profile': video.profile, + 'level': video.level, + 'title': video.title, + 'extendedDisplayTitle': video.displayTitle, + 'comment': video.comment, + 'language': video.language, + 'languageCode': video.languageCode, + 'bitrate': video.bitrateKbps, + 'default': video.isDefault, + 'selected': video.isSelected, + 'forced': video.isForced, + 'dub': video.isDub, + 'original': video.isOriginal, + 'width': video.width, + 'height': video.height, + 'codedWidth': video.codedWidth, + 'codedHeight': video.codedHeight, + 'frameRate': video.frameRate, + 'scanType': video.scanType, + 'bitDepth': video.bitDepth, + 'refFrames': video.refFrames, + 'colorSpace': video.colorSpace, + 'colorRange': video.colorRange, + 'colorPrimaries': video.colorPrimaries, + 'colorTrc': video.colorTransfer, + 'chromaSubsampling': video.chromaSubsampling, + 'chromaLocation': video.chromaLocation, + 'pixelAspectRatio': video.pixelAspectRatio, + 'anamorphic': video.isAnamorphic, + 'hasScalingMatrix': video.hasScalingMatrix, + 'streamIdentifier': video.streamIdentifier, + 'streamType': video.kind, + 'DOVIProfile': video.dolbyVision?.profile, + 'DOVILevel': video.dolbyVision?.level, + 'DOVIVersion': video.dolbyVision?.version, + 'DOVIBLCompatID': video.dolbyVision?.blCompatibilityId, + 'DOVIBLPresent': video.dolbyVision?.blPresent, + 'DOVIELPresent': video.dolbyVision?.elPresent, + 'DOVIRPUPresent': video.dolbyVision?.rpuPresent, + 'DOVIPresent': video.videoRange, + }, + nested: const {}, + // `displayTitle` is the short form of `extendedDisplayTitle`, which is + // what the stream card headline shows; `languageTag` is the same ISO + // code as `languageCode`. + excluded: const {'displayTitle', 'languageTag'}, + label: 'Plex video stream', + ); + + _expectClassified( + observed: _plexAudioStream, + carried: { + 'id': audio.id, + 'index': audio.index, + 'codec': audio.codec, + 'profile': audio.profile, + 'title': audio.title, + 'extendedDisplayTitle': audio.displayTitle, + 'comment': audio.comment, + 'language': audio.language, + 'languageCode': audio.languageCode, + 'bitrate': audio.bitrateKbps, + 'bitDepth': audio.bitDepth, + 'channels': audio.channels, + 'audioChannelLayout': audio.channelLayout, + 'samplingRate': audio.sampleRate, + 'default': audio.isDefault, + 'forced': audio.isForced, + 'selected': audio.isSelected, + 'dub': audio.isDub, + 'original': audio.isOriginal, + 'streamIdentifier': audio.streamIdentifier, + 'streamType': audio.kind, + }, + nested: const {}, + excluded: const {'displayTitle', 'languageTag'}, + label: 'Plex audio stream', + ); + + _expectClassified( + observed: _plexSubtitleStream, + carried: { + 'id': subtitle.id, + 'index': subtitle.index, + 'codec': subtitle.codec, + 'title': subtitle.title, + 'extendedDisplayTitle': subtitle.displayTitle, + 'language': subtitle.language, + 'languageCode': subtitle.languageCode, + 'bitrate': subtitle.bitrateKbps, + 'default': subtitle.isDefault, + 'selected': subtitle.isSelected, + 'forced': subtitle.isForced, + 'hearingImpaired': subtitle.isHearingImpaired, + 'descriptions': subtitle.hasDescriptions, + 'headerCompression': subtitle.headerCompression, + 'dub': subtitle.isDub, + 'original': subtitle.isOriginal, + 'key': subtitle.externalKey, + 'format': subtitle.subtitleFormat, + 'providerTitle': subtitle.providerTitle, + 'score': subtitle.score, + 'sourceKey': subtitle.sourceKey, + 'transient': subtitle.isTransient, + 'streamIdentifier': subtitle.streamIdentifier, + 'streamType': subtitle.kind, + }, + nested: const {}, + // `userID` is the Plex account that downloaded the subtitle — account + // identity, not a property of the file. + excluded: const {'displayTitle', 'languageTag', 'userID'}, + label: 'Plex subtitle stream', + ); + }); + + test('a sidecar subtitle key both marks the track external and is shown', () { + final info = parsePlexFileInfoFromJson(_plexMetadata)!; + final subtitle = info.versions.single.parts.single.streamsOfKind(MediaStreamKind.subtitle).single; + expect(subtitle.isExternal, isTrue); + expect(subtitle.externalKey, '/library/streams/47906'); + }); + + test('checkFiles flags survive as file presence', () { + final info = parsePlexFileInfoFromJson(_plexMetadata)!; + final part = info.versions.single.parts.single; + expect(part.exists, isTrue); + expect(part.accessible, isFalse); + }); + + test('an optimized version is identified by proxyType 42', () { + final version = parsePlexFileInfoFromJson(_plexMetadata)!.versions.single; + expect(version.isOptimizedVersion, isTrue); + expect(version.optimizationTarget, 'Mobile 720p'); + expect(version.deletedAt, DateTime.fromMillisecondsSinceEpoch(1780186199 * 1000)); + }); + + test('a version without the optimized-version keys reports null, not false', () { + final version = parsePlexFileInfoFromJson({ + 'Media': [ + { + 'container': 'mkv', + 'Part': [ + {'file': '/media/plain.mkv'}, + ], + }, + ], + })!.versions.single; + expect(version.isOptimizedVersion, isNull); + expect(version.optimizationTarget, isNull); + expect(version.deletedAt, isNull); + expect(version.parts.single.exists, isNull); + expect(version.parts.single.accessible, isNull); + }); + }); + + group('Jellyfin key coverage', () { + test('every populated MediaSource / MediaStream / MediaAttachment key is classified', () { + final info = parseJellyfinFileInfoFromJson(_jellyfinItem)!; + final version = info.versions.single; + final part = version.parts.single; + final video = part.streamsOfKind(MediaStreamKind.video).single; + final audio = part.streamsOfKind(MediaStreamKind.audio).single; + final subtitle = part.streamsOfKind(MediaStreamKind.subtitle).single; + final image = part.streamsOfKind(MediaStreamKind.image).single; + final lyric = part.streamsOfKind(MediaStreamKind.lyric).single; + expect(image.kind, MediaStreamKind.image); + expect(lyric.kind, MediaStreamKind.lyric); + final attachment = version.attachments.single; + + _expectClassified( + observed: _jellyfinSource, + carried: { + 'Id': version.id, + 'Name': version.title, + 'Container': version.container, + 'Bitrate': version.bitrateKbps, + 'RunTimeTicks': version.durationMs, + 'Path': part.filePath, + 'Size': part.fileSize, + 'ETag': version.eTag, + 'Protocol': version.protocol, + 'VideoType': version.videoType, + 'Type': version.sourceType, + 'IsRemote': version.isRemote, + 'IsInfiniteStream': version.isInfiniteStream, + 'SupportsDirectPlay': version.supportsDirectPlay, + 'SupportsDirectStream': version.supportsDirectStream, + 'SupportsTranscoding': version.supportsTranscoding, + 'DefaultAudioStreamIndex': version.defaultAudioStreamIndex, + 'DefaultSubtitleStreamIndex': version.defaultSubtitleStreamIndex, + 'Timestamp': version.transportStreamTimestamp, + }, + nested: const {'MediaStreams', 'MediaAttachments'}, + // ffmpeg-input plumbing and transcoder-contract flags: they describe + // how the server would remux the source, not the file on disk, and + // were constant across every sampled source. + excluded: const { + 'IgnoreDts', + 'IgnoreIndex', + 'GenPtsInput', + 'ReadAtNativeFramerate', + 'RequiresOpening', + 'RequiresClosing', + 'RequiresLooping', + 'SupportsProbing', + 'UseMostCompatibleTranscodingProfile', + 'TranscodingSubProtocol', + 'HasSegments', + 'Formats', + 'RequiredHttpHeaders', + }, + label: 'Jellyfin MediaSource', + ); + + _expectClassified( + observed: _jellyfinVideoStream, + carried: { + 'Type': video.kind, + 'Index': video.index, + 'Codec': video.codec, + 'CodecTag': video.codecTag, + 'Profile': video.profile, + 'Level': video.level, + 'Title': video.title, + 'DisplayTitle': video.displayTitle, + 'Language': video.language, + 'BitRate': video.bitrateKbps, + 'BitDepth': video.bitDepth, + 'Width': video.width, + 'Height': video.height, + 'AspectRatio': video.aspectRatio, + 'RealFrameRate': video.frameRate, + 'Rotation': video.rotation, + 'PixelFormat': video.pixelFormat, + 'RefFrames': video.refFrames, + 'IsInterlaced': video.isInterlaced, + 'IsAnamorphic': video.isAnamorphic, + 'IsAVC': video.isAvc, + 'NalLengthSize': video.nalLengthSize, + 'ColorSpace': video.colorSpace, + 'ColorRange': video.colorRange, + 'ColorPrimaries': video.colorPrimaries, + 'ColorTransfer': video.colorTransfer, + 'VideoRangeType': video.videoRange, + 'Hdr10PlusPresentFlag': video.hasHdr10Plus, + 'TimeBase': video.timeBase, + 'IsDefault': video.isDefault, + 'IsForced': video.isForced, + 'IsExternal': video.isExternal, + 'IsHearingImpaired': video.isHearingImpaired, + 'IsTextSubtitleStream': video.isTextSubtitle, + 'SupportsExternalStream': video.supportsExternalStream, + 'AudioSpatialFormat': video.spatialFormat, + 'DvProfile': video.dolbyVision?.profile, + 'DvLevel': video.dolbyVision?.level, + 'DvVersionMajor': video.dolbyVision?.version, + 'DvVersionMinor': video.dolbyVision?.version, + 'DvBlSignalCompatibilityId': video.dolbyVision?.blCompatibilityId, + 'BlPresentFlag': video.dolbyVision?.blPresent, + 'ElPresentFlag': video.dolbyVision?.elPresent, + 'RpuPresentFlag': video.dolbyVision?.rpuPresent, + 'VideoDoViTitle': video.dolbyVision?.title, + }, + nested: const {}, + // `VideoRange` is the coarse form of `VideoRangeType`; the three + // frame-rate fields carry the same value and `RealFrameRate` wins. + excluded: const {'VideoRange', 'AverageFrameRate', 'ReferenceFrameRate'}, + label: 'Jellyfin video stream', + ); + + _expectClassified( + observed: _jellyfinAudioStream, + carried: { + 'Type': audio.kind, + 'Index': audio.index, + 'Codec': audio.codec, + 'CodecTag': audio.codecTag, + 'Profile': audio.profile, + 'Level': audio.level, + 'Title': audio.title, + 'DisplayTitle': audio.displayTitle, + 'Language': audio.language, + 'BitRate': audio.bitrateKbps, + 'BitDepth': audio.bitDepth, + 'Channels': audio.channels, + 'ChannelLayout': audio.channelLayout, + 'SampleRate': audio.sampleRate, + 'AudioSpatialFormat': audio.spatialFormat, + 'TimeBase': audio.timeBase, + 'IsDefault': audio.isDefault, + 'IsForced': audio.isForced, + 'IsExternal': audio.isExternal, + 'IsHearingImpaired': audio.isHearingImpaired, + 'IsTextSubtitleStream': audio.isTextSubtitle, + 'SupportsExternalStream': audio.supportsExternalStream, + 'IsInterlaced': audio.isInterlaced, + 'IsAVC': audio.isAvc, + 'VideoRangeType': audio.videoRange, + }, + nested: const {}, + // `Localized*` are server-rendered captions for flags the sheet + // already renders as chips. + excluded: const {'VideoRange', 'LocalizedDefault', 'LocalizedExternal'}, + label: 'Jellyfin audio stream', + ); + + _expectClassified( + observed: _jellyfinSubtitleStream, + carried: { + 'Type': subtitle.kind, + 'Index': subtitle.index, + 'Codec': subtitle.codec, + 'CodecTag': subtitle.codecTag, + 'Level': subtitle.level, + 'Title': subtitle.title, + 'DisplayTitle': subtitle.displayTitle, + 'Language': subtitle.language, + 'Width': subtitle.width, + 'Height': subtitle.height, + 'BitRate': subtitle.bitrateKbps, + 'Score': subtitle.score, + 'TimeBase': subtitle.timeBase, + 'DeliveryUrl': subtitle.externalKey, + 'Path': subtitle.filePath, + 'IsDefault': subtitle.isDefault, + 'IsForced': subtitle.isForced, + 'IsExternal': subtitle.isExternal, + 'IsHearingImpaired': subtitle.isHearingImpaired, + 'IsTextSubtitleStream': subtitle.isTextSubtitle, + 'SupportsExternalStream': subtitle.supportsExternalStream, + 'IsInterlaced': subtitle.isInterlaced, + 'IsAVC': subtitle.isAvc, + 'AudioSpatialFormat': subtitle.spatialFormat, + 'VideoRangeType': subtitle.videoRange, + }, + nested: const {}, + excluded: const { + 'VideoRange', + 'LocalizedDefault', + 'LocalizedExternal', + 'LocalizedForced', + 'LocalizedHearingImpaired', + 'LocalizedUndefined', + }, + label: 'Jellyfin subtitle stream', + ); + + _expectClassified( + observed: _jellyfinEmbeddedImageStream, + carried: { + 'Type': image.kind, + 'Index': image.index, + 'Codec': image.codec, + 'Profile': image.profile, + 'Level': image.level, + 'BitDepth': image.bitDepth, + 'Width': image.width, + 'Height': image.height, + 'AspectRatio': image.aspectRatio, + 'RealFrameRate': image.frameRate, + 'PixelFormat': image.pixelFormat, + 'RefFrames': image.refFrames, + 'IsInterlaced': image.isInterlaced, + 'IsAnamorphic': image.isAnamorphic, + 'IsAVC': image.isAvc, + 'ColorSpace': image.colorSpace, + 'ColorPrimaries': image.colorPrimaries, + 'ColorTransfer': image.colorTransfer, + 'VideoRangeType': image.videoRange, + 'AudioSpatialFormat': image.spatialFormat, + 'TimeBase': image.timeBase, + 'IsDefault': image.isDefault, + 'IsForced': image.isForced, + 'IsExternal': image.isExternal, + 'IsHearingImpaired': image.isHearingImpaired, + 'IsTextSubtitleStream': image.isTextSubtitle, + 'SupportsExternalStream': image.supportsExternalStream, + 'Comment': image.comment, + }, + nested: const {}, + // `VideoRange` is the coarse form of `VideoRangeType`; + // `ReferenceFrameRate` duplicates `RealFrameRate`. + excluded: const {'VideoRange', 'ReferenceFrameRate'}, + label: 'Jellyfin embedded image stream', + ); + + _expectClassified( + observed: _jellyfinLyricStream, + carried: { + 'Type': lyric.kind, + 'Index': lyric.index, + 'AudioSpatialFormat': lyric.spatialFormat, + 'IsDefault': lyric.isDefault, + 'IsForced': lyric.isForced, + 'IsExternal': lyric.isExternal, + 'IsHearingImpaired': lyric.isHearingImpaired, + 'IsInterlaced': lyric.isInterlaced, + 'IsTextSubtitleStream': lyric.isTextSubtitle, + 'SupportsExternalStream': lyric.supportsExternalStream, + 'Path': lyric.filePath, + 'VideoRangeType': lyric.videoRange, + }, + nested: const {}, + // `VideoRange` is the coarse form of `VideoRangeType`. + excluded: const {'VideoRange'}, + label: 'Jellyfin lyric stream', + ); + + _expectClassified( + observed: _jellyfinAttachment, + carried: { + 'Index': attachment.index, + 'FileName': attachment.fileName, + 'MimeType': attachment.mimeType, + 'Codec': attachment.codec, + }, + nested: const {}, + // Live servers leave `Codec` null and populate `CodecTag`; the parser + // prefers `Codec` and falls back, so one row covers both. + excluded: const {'CodecTag'}, + label: 'Jellyfin attachment', + ); + }); + + test('attachment codec prefers Codec and falls back to CodecTag', () { + final withBoth = parseJellyfinFileInfoFromJson({ + 'MediaSources': [ + { + 'MediaAttachments': [ + {'Index': 1, 'FileName': 'a.ttf', 'Codec': 'ttf', 'CodecTag': '[0][0][0][0]'}, + {'Index': 2, 'FileName': 'b.ttf', 'CodecTag': '[0][0][0][0]'}, + ], + }, + ], + })!.versions.single.attachments; + expect(withBoth[0].codec, 'ttf'); + expect(withBoth[1].codec, '[0][0][0][0]'); + }); + + test('bool-ish scalars are tolerated the way Plex-shaped payloads are', () { + // Jellyfin normally sends proper JSON booleans, but proxies and older + // server builds have been seen echoing 0/1 or "true". + final stream = parseJellyfinFileInfoFromJson({ + 'MediaSources': [ + { + 'IsRemote': 'false', + 'SupportsDirectPlay': 1, + 'MediaStreams': [ + {'Type': 'Subtitle', 'Index': 0, 'IsForced': 'true', 'IsDefault': 0, 'IsTextSubtitleStream': 1}, + ], + }, + ], + })!.versions.single; + expect(stream.isRemote, isFalse); + expect(stream.supportsDirectPlay, isTrue); + final subtitle = stream.parts.single.streams.single; + expect(subtitle.isForced, isTrue); + expect(subtitle.isDefault, isFalse); + expect(subtitle.isTextSubtitle, isTrue); + }); + + test('a malformed sibling is skipped instead of sinking the whole sheet', () { + final info = parseJellyfinFileInfoFromJson({ + 'MediaSources': [ + { + 'Id': 'source-1', + 'MediaStreams': [ + {'Type': 'Video', 'Index': 0, 'Codec': 'h264'}, + 'not-a-stream', + {'Type': 'Audio', 'Index': 1, 'Codec': 'aac'}, + ], + 'MediaAttachments': [ + {'Index': 0, 'FileName': 'a.ttf'}, + 42, + ], + }, + 'not-a-source', + {'Id': 'source-2', 'Container': 'mp4'}, + ], + })!; + + expect(info.versions.map((version) => version.id), ['source-1', 'source-2']); + expect(info.versions.first.parts.single.streams.map((stream) => stream.codec), ['h264', 'aac']); + expect(info.versions.first.attachments.map((attachment) => attachment.fileName), ['a.ttf']); + }); + }); + + test('newly surfaced values keep their distinct semantics', () { + final plexInfo = parsePlexFileInfoFromJson(_plexMetadata)!; + expect(plexInfo.versions.single.displayOffsetPercent, 50); + final plexSubtitle = plexInfo.versions.single.parts.single.streamsOfKind(MediaStreamKind.subtitle).single; + expect(plexSubtitle.headerCompression, isTrue); + + final jellyfinInfo = parseJellyfinFileInfoFromJson(_jellyfinItem)!; + final jellyfinPart = jellyfinInfo.versions.single.parts.single; + final jellyfinVideo = jellyfinPart.streamsOfKind(MediaStreamKind.video).single; + final jellyfinSubtitle = jellyfinPart.streamsOfKind(MediaStreamKind.subtitle).single; + expect(jellyfinVideo.rotation, 90); + expect(jellyfinSubtitle.filePath, '/media/Movies/1917 (2019)/1917 (2019).eng.srt'); + expect(jellyfinSubtitle.externalKey, '/Videos/1/1/Subtitles/2/0/Stream.srt'); + }); +} + +/// Fails when [observed] carries a key that is neither projected onto the +/// model, recursed into, nor explicitly excluded — and when a key claimed as +/// carried did not actually survive the parse. +void _expectClassified({ + required Map observed, + required Map carried, + required Set nested, + required Set excluded, + required String label, +}) { + final classified = {...carried.keys, ...nested, ...excluded}; + final unclassified = observed.keys.where((key) => !classified.contains(key)).toList()..sort(); + expect( + unclassified, + isEmpty, + reason: + '$label exposes ${unclassified.join(', ')}. Map it onto MediaFileInfo, ' + 'fold it into an existing field, or add it to the exclusion set with a reason.', + ); + + final dropped = carried.entries.where((entry) => observed[entry.key] != null && entry.value == null).toList(); + expect( + dropped.map((entry) => entry.key).toList()..sort(), + isEmpty, + reason: '$label claims to carry ${dropped.map((e) => e.key).join(', ')} but the parsed value is null.', + ); +} + +// --- Plex fixture: every key the production request shape returns ---------- + +const _plexVideoStream = { + 'id': 10032, + 'streamType': 1, + 'index': 0, + 'codec': 'hevc', + 'codecID': 'dvhe', + 'profile': 'main 10', + 'level': 153, + 'title': 'Presented By EMBER', + 'displayTitle': '4K DoVi', + 'extendedDisplayTitle': '4K DoVi/HDR10 (HEVC Main 10)', + 'language': 'English', + 'languageTag': 'en', + 'languageCode': 'eng', + 'bitrate': 50949, + 'bitDepth': 10, + 'width': 3840, + 'height': 2160, + 'codedWidth': 3840, + 'codedHeight': 2160, + 'frameRate': 23.976, + 'scanType': 'progressive', + 'refFrames': 1, + 'colorSpace': 'bt2020nc', + 'colorRange': 'tv', + 'colorPrimaries': 'bt2020', + 'colorTrc': 'smpte2084', + 'chromaSubsampling': '4:2:0', + 'chromaLocation': 'topleft', + 'pixelAspectRatio': '32:27', + 'anamorphic': true, + 'hasScalingMatrix': false, + 'comment': 'Presented By EMBER', + 'dub': false, + 'original': true, + 'forced': false, + 'streamIdentifier': '4117', + 'default': true, + 'selected': true, + 'DOVIPresent': true, + 'DOVIProfile': 8, + 'DOVILevel': 10, + 'DOVIVersion': '1.0', + 'DOVIBLCompatID': 1, + 'DOVIBLPresent': true, + 'DOVIELPresent': false, + 'DOVIRPUPresent': true, +}; + +const _plexAudioStream = { + 'id': 41919, + 'streamType': 2, + 'index': 1, + 'codec': 'eac3', + 'profile': 'dts', + 'title': 'English 5.1', + 'displayTitle': 'English (EAC3 5.1)', + 'extendedDisplayTitle': 'English (EAC3 5.1)', + 'comment': 'Dolby Atmos', + 'language': 'English', + 'languageTag': 'en', + 'languageCode': 'eng', + 'bitrate': 768, + 'bitDepth': 24, + 'channels': 6, + 'audioChannelLayout': '5.1', + 'samplingRate': 48000, + 'default': true, + 'forced': false, + 'selected': true, + 'dub': true, + 'original': true, + 'streamIdentifier': '3', +}; + +const _plexSubtitleStream = { + 'id': 49700, + 'streamType': 3, + 'index': 2, + 'codec': 'srt', + 'title': 'Full Subtitles [Cyan]', + 'displayTitle': 'English', + 'extendedDisplayTitle': 'Full subtitles (English SRT)', + 'language': 'English', + 'languageTag': 'en', + 'languageCode': 'eng', + 'bitrate': 2647, + 'default': true, + 'selected': true, + 'forced': false, + 'descriptions': false, + 'headerCompression': '1', + 'dub': false, + 'original': true, + 'hearingImpaired': true, + 'key': '/library/streams/47906', + 'format': 'srt', + 'providerTitle': 'OpenSubtitles', + 'score': 660, + 'sourceKey': '/library/streams/10522480', + 'transient': true, + 'streamIdentifier': '4', + 'userID': 625113360, +}; + +const _plexPart = { + 'id': 8930, + 'key': '/library/parts/9155/1773334775/file.mkv', + 'file': '/media/Movies/Example (2019)/Example (2019).mkv', + 'size': 28372183040, + 'container': 'mkv', + 'duration': 7139000, + 'indexes': 'sd', + 'hasThumbnail': true, + 'has64bitOffsets': false, + 'optimizedForStreaming': true, + 'packetLength': 188, + 'exists': true, + 'accessible': false, + 'failureBIFResultCode': 2, + 'failureBIFRetryCount': 3, + 'videoProfile': 'main 10', + 'audioProfile': 'dts', + 'Stream': [_plexVideoStream, _plexAudioStream, _plexSubtitleStream], +}; + +const _plexMedia = { + 'id': 10032, + 'title': 'Original', + 'container': 'mkv', + 'bitrate': 36400, + 'duration': 7139000, + 'width': 3840, + 'height': 2160, + 'aspectRatio': 2.4, + 'videoResolution': '4k', + 'videoCodec': 'hevc', + 'videoProfile': 'main 10', + 'videoFrameRate': '24p', + 'audioCodec': 'eac3', + 'audioProfile': 'dts', + 'audioChannels': 6, + 'optimizedForStreaming': true, + 'has64bitOffsets': false, + 'proxyType': 42, + 'target': 'Mobile 720p', + 'deletedAt': 1780186199, + 'displayOffset': 50, + 'Part': [_plexPart], +}; + +const _plexMetadata = { + 'Media': [_plexMedia], +}; + +// --- Jellyfin fixture ------------------------------------------------------ + +const _jellyfinVideoStream = { + 'Type': 'Video', + 'Index': 0, + 'Codec': 'hevc', + 'CodecTag': 'hvc1', + 'Profile': 'Main 10', + 'Level': 153, + 'Title': 'Remux', + 'DisplayTitle': '4K HEVC Dolby Vision Profile 8.1 (HDR10)', + 'Language': 'eng', + 'BitRate': 31800000, + 'BitDepth': 10, + 'Width': 3840, + 'Height': 1604, + 'AspectRatio': '2.40:1', + 'AverageFrameRate': 23.976, + 'RealFrameRate': 23.976, + 'ReferenceFrameRate': 23.976, + 'Rotation': 90, + 'PixelFormat': 'yuv420p10le', + 'RefFrames': 1, + 'IsInterlaced': false, + 'IsAnamorphic': false, + 'IsAVC': false, + 'NalLengthSize': 4, + 'ColorSpace': 'bt2020nc', + 'ColorRange': 'tv', + 'ColorPrimaries': 'bt2020', + 'ColorTransfer': 'smpte2084', + 'VideoRange': 'HDR', + 'VideoRangeType': 'DOVIWithHDR10Plus', + 'Hdr10PlusPresentFlag': true, + 'AudioSpatialFormat': 'None', + 'TimeBase': '1/24000', + 'IsDefault': true, + 'IsForced': false, + 'IsExternal': false, + 'IsHearingImpaired': false, + 'IsTextSubtitleStream': false, + 'SupportsExternalStream': false, + 'DvProfile': 8, + 'DvLevel': 10, + 'DvVersionMajor': 1, + 'DvVersionMinor': 0, + 'DvBlSignalCompatibilityId': 1, + 'BlPresentFlag': true, + 'ElPresentFlag': false, + 'RpuPresentFlag': true, + 'VideoDoViTitle': 'Dolby Vision Profile 8.1 (HDR10)', +}; + +const _jellyfinAudioStream = { + 'Type': 'Audio', + 'Index': 1, + 'Codec': 'truehd', + 'CodecTag': 'ac-3', + 'Profile': 'Dolby TrueHD + Dolby Atmos', + 'Level': 4, + 'Title': 'Surround 7.1', + 'DisplayTitle': 'Surround 7.1 - English - Dolby TrueHD + Dolby Atmos - Default', + 'Language': 'eng', + 'BitRate': 4500000, + 'BitDepth': 24, + 'Channels': 8, + 'ChannelLayout': '7.1', + 'SampleRate': 48000, + 'AudioSpatialFormat': 'DolbyAtmos', + 'TimeBase': '1/1000', + 'IsDefault': true, + 'IsForced': false, + 'IsExternal': false, + 'IsHearingImpaired': false, + 'IsInterlaced': false, + 'IsAVC': false, + 'IsTextSubtitleStream': false, + 'SupportsExternalStream': false, + 'VideoRange': 'Unknown', + 'VideoRangeType': 'Unknown', + 'LocalizedDefault': 'Default', + 'LocalizedExternal': 'External', +}; + +const _jellyfinSubtitleStream = { + 'Type': 'Subtitle', + 'Index': 2, + 'Codec': 'SUBRIP', + 'CodecTag': '[0][0][0][0]', + 'Level': 0, + 'Title': 'English', + 'DisplayTitle': 'English - SUBRIP', + 'Language': 'eng', + 'BitRate': 128000, + 'Width': 1920, + 'Height': 1080, + 'Score': 121211, + 'TimeBase': '1/1000', + 'DeliveryUrl': '/Videos/1/1/Subtitles/2/0/Stream.srt', + 'Path': '/media/Movies/1917 (2019)/1917 (2019).eng.srt', + 'IsDefault': false, + 'IsForced': false, + 'IsExternal': true, + 'IsHearingImpaired': false, + 'IsInterlaced': false, + 'IsAVC': false, + 'IsTextSubtitleStream': true, + 'SupportsExternalStream': true, + 'AudioSpatialFormat': 'None', + 'VideoRange': 'Unknown', + 'VideoRangeType': 'Unknown', + 'LocalizedDefault': 'Default', + 'LocalizedExternal': 'External', + 'LocalizedForced': 'Forced', + 'LocalizedHearingImpaired': 'Hearing Impaired', + 'LocalizedUndefined': 'Undefined', +}; + +const _jellyfinEmbeddedImageStream = { + 'Type': 'EmbeddedImage', + 'Index': 3, + 'Codec': 'mjpeg', + 'Profile': 'Baseline', + 'Level': 31, + 'BitDepth': 8, + 'Width': 600, + 'Height': 900, + 'AspectRatio': '2:3', + 'RealFrameRate': 0.1, + 'ReferenceFrameRate': 0.1, + 'PixelFormat': 'yuvj420p', + 'RefFrames': 1, + 'IsInterlaced': false, + 'IsAnamorphic': false, + 'IsAVC': false, + 'ColorSpace': 'bt470bg', + 'ColorPrimaries': 'bt709', + 'ColorTransfer': 'bt709', + 'VideoRange': 'SDR', + 'VideoRangeType': 'SDR', + 'AudioSpatialFormat': 'None', + 'TimeBase': '1/90000', + 'IsDefault': false, + 'IsForced': false, + 'IsExternal': false, + 'IsHearingImpaired': false, + 'IsTextSubtitleStream': false, + 'SupportsExternalStream': false, + 'Comment': 'Cover (front)', +}; + +const _jellyfinLyricStream = { + 'Type': 'Lyric', + 'Index': 4, + 'AudioSpatialFormat': 'None', + 'IsDefault': false, + 'IsForced': false, + 'IsExternal': true, + 'IsHearingImpaired': false, + 'IsInterlaced': false, + 'IsTextSubtitleStream': true, + 'SupportsExternalStream': true, + 'Path': '/media/Movies/1917 (2019)/1917 (2019).lrc', + 'VideoRange': 'Unknown', + 'VideoRangeType': 'Unknown', +}; + +const _jellyfinAttachment = { + 'Index': 6, + 'FileName': 'akbar.ttf', + 'MimeType': 'font/ttf', + 'Codec': 'ttf', + 'CodecTag': '[0][0][0][0]', +}; + +const _jellyfinSource = { + 'Id': '5deccd65b8114bdab312be160437eecb', + 'Name': '1917 (2019)', + 'Path': '/media/Movies/1917 (2019)/1917 (2019).mkv', + 'Container': 'mkv', + 'Size': 28372183040, + 'ETag': '4581fa214aba4b58ce02ca1e29ea5e0e', + 'Timestamp': 'Valid', + 'Bitrate': 36400000, + 'RunTimeTicks': 71390000000, + 'Protocol': 'File', + 'Type': 'Default', + 'VideoType': 'VideoFile', + 'IsRemote': false, + 'IsInfiniteStream': false, + 'SupportsDirectPlay': true, + 'SupportsDirectStream': true, + 'SupportsTranscoding': true, + 'DefaultAudioStreamIndex': 1, + 'DefaultSubtitleStreamIndex': 2, + 'IgnoreDts': false, + 'IgnoreIndex': false, + 'GenPtsInput': false, + 'ReadAtNativeFramerate': false, + 'RequiresOpening': false, + 'RequiresClosing': false, + 'RequiresLooping': false, + 'SupportsProbing': true, + 'UseMostCompatibleTranscodingProfile': false, + 'TranscodingSubProtocol': 'http', + 'HasSegments': false, + 'Formats': [], + 'RequiredHttpHeaders': {}, + 'MediaStreams': [ + _jellyfinVideoStream, + _jellyfinAudioStream, + _jellyfinSubtitleStream, + _jellyfinEmbeddedImageStream, + _jellyfinLyricStream, + ], + 'MediaAttachments': [_jellyfinAttachment], +}; + +const _jellyfinItem = { + 'MediaSources': [_jellyfinSource], +}; diff --git a/test/services/media_file_info_parsers_test.dart b/test/services/media_file_info_parsers_test.dart new file mode 100644 index 00000000..9149ce36 --- /dev/null +++ b/test/services/media_file_info_parsers_test.dart @@ -0,0 +1,560 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_file_info.dart'; +import 'package:plezy/services/jellyfin_client.dart'; +import 'package:plezy/services/plex_playback_mapper.dart'; + +void main() { + group('parsePlexFileInfoFromJson', () { + test('keeps every server-ordered media version with its own summary', () { + final result = parsePlexFileInfoFromJson({ + 'Media': [ + { + 'id': 'first', + 'title': 'Original', + 'container': 'mkv', + 'width': '3840', + 'height': '2160', + 'videoResolution': '4k', + }, + { + 'id': 'second', + 'title': 'Mobile', + 'container': 'mp4', + 'width': 1280, + 'height': 720, + 'videoResolution': '720', + }, + ], + }); + + expect(result, isNotNull); + expect(result!.versions, hasLength(2)); + expect(result.versions.map((version) => version.id), ['first', 'second']); + expect(result.versions.map((version) => version.title), ['Original', 'Mobile']); + expect(result.versions.map((version) => version.container), ['mkv', 'mp4']); + expect(result.versions.map((version) => version.resolutionFormatted), ['3840x2160', '1280x720']); + }); + + test('keeps every part with independent files and streams and sums their sizes', () { + final result = parsePlexFileInfoFromJson({ + 'Media': [ + { + 'Part': [ + { + 'id': 'part-1', + 'file': '/media/movie-cd1.mkv', + 'size': '1200', + 'Stream': [ + {'id': '11', 'streamType': 1, 'codec': 'h264'}, + ], + }, + { + 'id': 'part-2', + 'file': '/media/movie-cd2.mkv', + 'size': 3400, + 'Stream': [ + {'id': '21', 'streamType': 2, 'codec': 'aac'}, + {'id': '22', 'streamType': 3, 'codec': 'srt'}, + ], + }, + ], + }, + ], + }); + + final version = result!.versions.single; + expect(version.parts, hasLength(2)); + expect(version.parts.map((part) => part.filePath), ['/media/movie-cd1.mkv', '/media/movie-cd2.mkv']); + expect(version.parts.map((part) => part.fileSize), [1200, 3400]); + expect(version.parts[0].streams.map((stream) => stream.kind), [MediaStreamKind.video]); + expect(version.parts[1].streams.map((stream) => stream.kind), [MediaStreamKind.audio, MediaStreamKind.subtitle]); + expect(version.totalFileSize, 4600); + }); + + test('accepts single Part and Stream objects instead of arrays', () { + final result = parsePlexFileInfoFromJson({ + 'Media': [ + { + 'Part': { + 'id': 9, + 'file': '/media/single.mp4', + 'Stream': {'id': 12, 'streamType': 1, 'codec': 'hevc'}, + }, + }, + ], + }); + + final part = result!.versions.single.parts.single; + expect(part.id, '9'); + expect(part.filePath, '/media/single.mp4'); + expect(part.streams, hasLength(1)); + expect(part.streams.single.codec, 'hevc'); + }); + + test('projects detailed video fields and leaves Plex bitrate in kbps', () { + final result = parsePlexFileInfoFromJson({ + 'Media': [ + { + 'Part': [ + { + 'Stream': [ + { + 'id': '101', + 'streamType': '1', + 'index': '7', + 'codec': 'hevc', + 'codecID': 'hvc1', + 'profile': 'Main 10', + 'level': '153', + 'bitrate': '17758', + 'width': '3840', + 'height': 1608, + 'codedWidth': '3840', + 'codedHeight': '1616', + 'frameRate': '23.976', + 'bitDepth': '10', + 'refFrames': '4', + 'colorSpace': 'bt2020nc', + 'colorRange': 'tv', + 'colorPrimaries': 'bt2020', + 'colorTrc': 'smpte2084', + 'chromaSubsampling': '4:2:0', + 'chromaLocation': 'topleft', + 'displayTitle': 'fallback title', + 'extendedDisplayTitle': '4K HEVC Main 10', + }, + ], + }, + ], + }, + ], + }); + + final stream = result!.versions.single.parts.single.streams.single; + expect(stream.kind, MediaStreamKind.video); + expect(stream.index, 7); + expect(stream.codec, 'hevc'); + expect(stream.codecTag, 'hvc1'); + expect(stream.profile, 'Main 10'); + expect(stream.level, 153); + expect(stream.bitrateKbps, 17758); + expect(stream.width, 3840); + expect(stream.height, 1608); + expect(stream.codedWidth, 3840); + expect(stream.codedHeight, 1616); + expect(stream.frameRate, closeTo(23.976, 0.0001)); + expect(stream.bitDepth, 10); + expect(stream.refFrames, 4); + expect(stream.colorSpace, 'bt2020nc'); + expect(stream.colorRange, 'tv'); + expect(stream.colorPrimaries, 'bt2020'); + expect(stream.colorTransfer, 'smpte2084'); + expect(stream.chromaSubsampling, '4:2:0'); + expect(stream.chromaLocation, 'topleft'); + expect(stream.displayTitle, '4K HEVC Main 10'); + }); + + test('numbers audio and subtitle streams independently while preserving container indexes', () { + final streams = _plexStreams([ + {'streamType': 2, 'index': 5, 'codec': 'aac'}, + {'streamType': 3, 'index': 3, 'codec': 'srt'}, + {'streamType': 2, 'index': 8, 'codec': 'eac3'}, + {'streamType': 3, 'index': 9, 'codec': 'ass'}, + ]); + + final audio = streams.where((stream) => stream.kind == MediaStreamKind.audio).toList(); + final subtitles = streams.where((stream) => stream.kind == MediaStreamKind.subtitle).toList(); + expect(audio.map((stream) => stream.ordinal), [1, 2]); + expect(audio.map((stream) => stream.index), [5, 8]); + expect(subtitles.map((stream) => stream.ordinal), [1, 2]); + expect(subtitles.map((stream) => stream.index), [3, 9]); + }); + + test('coerces string-ish flags without turning absent flags into false', () { + final stream = _plexStreams([ + {'streamType': 3, 'selected': '1', 'forced': 1, 'default': true}, + ]).single; + + expect(stream.isSelected, isTrue); + expect(stream.isForced, isTrue); + expect(stream.isDefault, isTrue); + expect(stream.isHearingImpaired, isNull); + }); + + test('maps Dolby Vision metadata only when DOVIPresent is true', () { + final streams = _plexStreams([ + { + 'streamType': 1, + 'DOVIPresent': '1', + 'DOVIProfile': '8', + 'DOVILevel': 6, + 'DOVIVersion': '1.0', + 'DOVIBLCompatID': '6', + 'DOVIBLPresent': true, + 'DOVIELPresent': 0, + 'DOVIRPUPresent': '1', + }, + {'streamType': 1, 'DOVIProfile': 5}, + ]); + + final dolbyVision = streams.first.dolbyVision!; + expect(dolbyVision.profile, 8); + expect(dolbyVision.level, 6); + expect(dolbyVision.version, '1.0'); + expect(dolbyVision.blCompatibilityId, 6); + expect(dolbyVision.blPresent, isTrue); + expect(dolbyVision.elPresent, isFalse); + expect(dolbyVision.rpuPresent, isTrue); + expect(dolbyVision.profileFormatted, 'Profile 8.6'); + expect(streams.last.dolbyVision, isNull); + }); + + test('derives video range from Dolby Vision and transfer characteristics', () { + final streams = _plexStreams([ + {'streamType': 1, 'DOVIPresent': true, 'DOVIProfile': 8, 'DOVIBLCompatID': 1}, + {'streamType': 1, 'DOVIPresent': true, 'DOVIProfile': 8, 'DOVIBLCompatID': 6}, + {'streamType': 1, 'DOVIPresent': true, 'DOVIProfile': 8, 'DOVIBLCompatID': 4}, + {'streamType': 1, 'DOVIPresent': true, 'DOVIProfile': 8, 'DOVIBLCompatID': 2}, + {'streamType': 1, 'DOVIPresent': true, 'DOVIProfile': 7}, + {'streamType': 1, 'DOVIPresent': true, 'DOVIProfile': 5, 'DOVIBLCompatID': 0}, + {'streamType': 1, 'DOVIPresent': true, 'DOVIProfile': 7, 'DOVIBLCompatID': 4}, + {'streamType': 1, 'DOVIPresent': true, 'DOVIProfile': 5, 'colorTrc': 'smpte2084'}, + {'streamType': 1, 'colorTrc': 'smpte2084'}, + {'streamType': 1, 'colorTrc': 'arib-std-b67'}, + {'streamType': 1, 'colorTrc': 'bt709'}, + {'streamType': 1}, + ]); + + expect(streams.map((stream) => stream.videoRange), [ + MediaVideoRange.dolbyVisionHdr10, + MediaVideoRange.dolbyVisionHdr10, + MediaVideoRange.dolbyVisionHlg, + MediaVideoRange.dolbyVision, + MediaVideoRange.dolbyVisionHdr10, + MediaVideoRange.dolbyVision, + MediaVideoRange.dolbyVisionHlg, + MediaVideoRange.dolbyVision, + MediaVideoRange.hdr10, + MediaVideoRange.hlg, + MediaVideoRange.sdr, + MediaVideoRange.unknown, + ]); + expect(streams[0].dolbyVision!.profileFormatted, 'Profile 8.1'); + expect(streams[5].dolbyVision!.profileFormatted, 'Profile 5'); + expect(streams[7].dolbyVision!.profileFormatted, 'Profile 5'); + }); + + test('marks only keyed subtitle streams as external', () { + final streams = _plexStreams([ + {'streamType': 3, 'key': '/library/streams/123'}, + {'streamType': 3}, + {'streamType': 1, 'key': '/library/streams/not-a-subtitle'}, + ]); + + expect(streams[0].isExternal, isTrue); + expect(streams[1].isExternal, isFalse); + expect(streams[2].isExternal, isNull); + }); + + test('a malformed stream map does not discard valid siblings', () { + final streams = _plexStreams([ + {'streamType': 1, 'codec': 'h264'}, + {1: 'non-string map key'}, + {'streamType': 2, 'codec': 'aac'}, + ]); + + expect(streams.map((stream) => stream.codec), ['h264', 'aac']); + }); + + test('returns null for null metadata or metadata without Media', () { + expect(parsePlexFileInfoFromJson(null), isNull); + expect(parsePlexFileInfoFromJson(const {}), isNull); + }); + }); + + group('parseJellyfinFileInfoFromJson', () { + test('keeps every media source as a one-part version in server order', () { + final result = parseJellyfinFileInfoFromJson({ + 'MediaSources': [ + {'Id': 'source-1', 'Name': 'Original', 'Path': '/media/original.mkv', 'Size': 9000}, + {'Id': 'source-2', 'Name': 'Transcoded', 'Path': '/media/transcoded.mp4', 'Size': '4000'}, + ], + }); + + expect(result, isNotNull); + expect(result!.versions.map((version) => version.id), ['source-1', 'source-2']); + expect(result.versions.every((version) => version.parts.length == 1), isTrue); + expect(result.versions.map((version) => version.parts.single.filePath), [ + '/media/original.mkv', + '/media/transcoded.mp4', + ]); + expect(result.versions.map((version) => version.parts.single.fileSize), [9000, 4000]); + }); + + test('normalizes bitrates and converts ticks into duration fields', () { + final version = _jellyfinVersions([ + { + 'Id': 'source', + 'Bitrate': 3184421, + 'RunTimeTicks': 50250000000, + 'MediaStreams': [ + {'Type': 'Video', 'BitRate': 1775759}, + ], + }, + ]).single; + + expect(version.bitrateKbps, 3184); + expect(version.durationMs, 5025000); + expect(version.durationFormatted, '1h 23m 45s'); + expect(version.parts.single.durationMs, 5025000); + expect(version.parts.single.streams.single.bitrateKbps, 1776); + }); + + test('prefers declared aspect ratio then dimensions and otherwise leaves it absent', () { + final versions = _jellyfinVersions([ + { + 'MediaStreams': [ + {'Type': 'Video', 'AspectRatio': '2.35:1', 'Width': 1920, 'Height': 1080}, + ], + }, + { + 'MediaStreams': [ + {'Type': 'Video', 'Width': 1920, 'Height': 1080}, + ], + }, + { + 'MediaStreams': [ + {'Type': 'Video'}, + ], + }, + ]); + + expect(versions[0].aspectRatio, closeTo(2.35, 0.0001)); + expect(versions[1].aspectRatio, closeTo(16 / 9, 0.0001)); + expect(versions[2].aspectRatio, isNull); + }); + + test('rolls up summary fields from the first video and audio streams', () { + final version = _jellyfinVersions([ + { + 'MediaStreams': [ + {'Type': 'Video', 'Codec': 'hevc', 'Profile': 'Main 10', 'Width': 3840, 'Height': 1608}, + {'Type': 'Video', 'Codec': 'h264', 'Profile': 'High', 'Width': 1920, 'Height': 1080}, + {'Type': 'Audio', 'Codec': 'eac3', 'Profile': 'Dolby Digital Plus', 'Channels': 6}, + {'Type': 'Audio', 'Codec': 'aac', 'Channels': 2}, + ], + }, + ]).single; + + expect(version.videoCodec, 'hevc'); + expect(version.videoProfile, 'Main 10'); + expect(version.audioCodec, 'eac3'); + expect(version.audioProfile, 'Dolby Digital Plus'); + expect(version.audioChannels, 6); + expect(version.videoResolutionLabel, '4k'); + }); + + test('maps every VideoRangeType and falls back to VideoRange', () { + final streams = _jellyfinStreams([ + {'Type': 'Video', 'VideoRangeType': 'SDR'}, + {'Type': 'Video', 'VideoRangeType': 'HDR10'}, + {'Type': 'Video', 'VideoRangeType': 'HDR10Plus'}, + {'Type': 'Video', 'VideoRangeType': 'HLG'}, + {'Type': 'Video', 'VideoRangeType': 'DOVI'}, + {'Type': 'Video', 'VideoRangeType': 'DOVIWithHDR10'}, + {'Type': 'Video', 'VideoRangeType': 'DOVIWithHDR10Plus'}, + {'Type': 'Video', 'VideoRangeType': 'DOVIWithHLG'}, + {'Type': 'Video', 'VideoRangeType': 'Unknown', 'VideoRange': 'HDR'}, + {'Type': 'Video', 'VideoRangeType': 'Unknown', 'VideoRange': 'SDR'}, + {'Type': 'Video'}, + ]); + + expect(streams.map((stream) => stream.videoRange), [ + MediaVideoRange.sdr, + MediaVideoRange.hdr10, + MediaVideoRange.hdr10Plus, + MediaVideoRange.hlg, + MediaVideoRange.dolbyVision, + MediaVideoRange.dolbyVisionHdr10, + MediaVideoRange.dolbyVisionHdr10, + MediaVideoRange.dolbyVisionHlg, + MediaVideoRange.hdr, + MediaVideoRange.sdr, + MediaVideoRange.unknown, + ]); + }); + + test('maps Dolby Vision fields only when present', () { + final streams = _jellyfinStreams([ + { + 'Type': 'Video', + 'DvProfile': 8, + 'DvLevel': '6', + 'DvVersionMajor': 1, + 'DvVersionMinor': 0, + 'DvBlSignalCompatibilityId': 6, + 'BlPresentFlag': true, + 'ElPresentFlag': false, + 'RpuPresentFlag': true, + 'VideoDoViTitle': 'Dolby Vision Profile 8.6', + }, + {'Type': 'Video', 'Codec': 'h264'}, + ]); + + final dolbyVision = streams.first.dolbyVision!; + expect(dolbyVision.profile, 8); + expect(dolbyVision.level, 6); + expect(dolbyVision.version, '1.0'); + expect(dolbyVision.blCompatibilityId, 6); + expect(dolbyVision.blPresent, isTrue); + expect(dolbyVision.elPresent, isFalse); + expect(dolbyVision.rpuPresent, isTrue); + expect(dolbyVision.title, 'Dolby Vision Profile 8.6'); + expect(streams.last.dolbyVision, isNull); + }); + + test('derives progressive and interlaced scan types from IsInterlaced', () { + final streams = _jellyfinStreams([ + {'Type': 'Video', 'IsInterlaced': false}, + {'Type': 'Video', 'IsInterlaced': true}, + ]); + + expect(streams[0].isInterlaced, isFalse); + expect(streams[0].scanType, 'progressive'); + expect(streams[1].isInterlaced, isTrue); + expect(streams[1].scanType, 'interlaced'); + }); + + test('keeps embedded image and data streams', () { + final streams = _jellyfinStreams([ + {'Type': 'EmbeddedImage', 'Codec': 'mjpeg'}, + {'Type': 'Data', 'Codec': 'bin_data'}, + ]); + + expect(streams.map((stream) => stream.kind), [MediaStreamKind.image, MediaStreamKind.data]); + expect(streams.map((stream) => stream.codec), ['mjpeg', 'bin_data']); + }); + + test('maps CodecTag attachments and prefers an explicit Codec', () { + final version = _jellyfinVersions([ + { + 'MediaAttachments': [ + {'Index': 4, 'FileName': 'OpenSans.ttf', 'MimeType': 'font/ttf', 'CodecTag': 'ttf'}, + { + 'Index': 5, + 'FileName': 'metadata.bin', + 'MimeType': 'application/octet-stream', + 'Codec': 'explicit-codec', + 'CodecTag': 'fallback-tag', + }, + ], + }, + ]).single; + + expect(version.attachments, hasLength(2)); + expect(version.attachments.first.index, 4); + expect(version.attachments.first.fileName, 'OpenSans.ttf'); + expect(version.attachments.first.mimeType, 'font/ttf'); + expect(version.attachments.first.codec, 'ttf'); + expect(version.attachments.last.codec, 'explicit-codec'); + }); + + test('returns null for missing empty or non-list MediaSources', () { + expect(parseJellyfinFileInfoFromJson(const {}), isNull); + expect(parseJellyfinFileInfoFromJson(const {'MediaSources': []}), isNull); + expect( + parseJellyfinFileInfoFromJson(const { + 'MediaSources': {'Id': 'source'}, + }), + isNull, + ); + }); + }); + + group('Media file-info formatting', () { + test('formats channel layouts and counts without empty punctuation', () { + expect(_stream(channels: 6, channelLayout: '5.1').channelsFormatted, '5.1 (6 ch)'); + expect(_stream(channelLayout: '5.1').channelsFormatted, '5.1'); + expect(_stream(channels: 6).channelsFormatted, '6 ch'); + expect(_stream().channelsFormatted, isNull); + }); + + test('formats whole and fractional sample rates in kHz', () { + expect(_stream(sampleRate: 48000).sampleRateFormatted, '48 kHz'); + expect(_stream(sampleRate: 44100).sampleRateFormatted, '44.1 kHz'); + }); + + test('formats whole and fractional frame rates without spurious decimals', () { + expect(_stream(frameRate: 24.0).frameRateFormatted, '24 fps'); + expect(_stream(frameRate: 23.976).frameRateFormatted, '23.976 fps'); + }); + + test('extracts filenames from POSIX and Windows paths', () { + expect(const MediaFilePart(filePath: '/media/movies/Movie.mkv').fileName, 'Movie.mkv'); + expect(const MediaFilePart(filePath: r'C:\Media\Movies\Movie.mkv').fileName, 'Movie.mkv'); + expect(const MediaFilePart(filePath: '').fileName, isNull); + }); + + test('formats durations at hour minute second boundaries', () { + expect(formatMediaDuration(5025000), '1h 23m 45s'); + expect(formatMediaDuration(1425000), '23m 45s'); + expect(formatMediaDuration(45000), '45s'); + expect(formatMediaDuration(null), isNull); + }); + + test('headline falls back through display title title language codec and ordinal', () { + expect(_stream(displayTitle: 'Display', title: 'Title', language: 'English', codec: 'aac').headline, 'Display'); + expect(_stream(title: 'Title', language: 'English', codec: 'aac').headline, 'Title'); + expect(_stream(language: 'English', codec: 'aac').headline, 'English'); + expect(_stream(codec: 'aac').headline, 'aac'); + expect(_stream(ordinal: 7).headline, '#7'); + }); + }); +} + +List _plexStreams(List streams) { + return parsePlexFileInfoFromJson({ + 'Media': [ + { + 'Part': [ + {'Stream': streams}, + ], + }, + ], + })!.versions.single.parts.single.streams; +} + +List _jellyfinVersions(List> sources) { + return parseJellyfinFileInfoFromJson({'MediaSources': sources})!.versions; +} + +List _jellyfinStreams(List> streams) { + return _jellyfinVersions([ + {'MediaStreams': streams}, + ]).single.parts.single.streams; +} + +MediaStreamDetails _stream({ + int ordinal = 1, + String? displayTitle, + String? title, + String? language, + String? codec, + int? channels, + String? channelLayout, + int? sampleRate, + double? frameRate, +}) { + return MediaStreamDetails( + kind: MediaStreamKind.audio, + ordinal: ordinal, + displayTitle: displayTitle, + title: title, + language: language, + codec: codec, + channels: channels, + channelLayout: channelLayout, + sampleRate: sampleRate, + frameRate: frameRate, + ); +} diff --git a/test/services/plex_file_info_cache_test.dart b/test/services/plex_file_info_cache_test.dart new file mode 100644 index 00000000..0ff8e21c --- /dev/null +++ b/test/services/plex_file_info_cache_test.dart @@ -0,0 +1,250 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/media_file_info.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/services/plex_api_cache.dart'; +import 'package:plezy/services/plex_client.dart'; +import 'package:plezy/utils/active_client_scope.dart'; + +import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/media_items.dart'; + +/// `/library/metadata/{id}` is a shared cache row, and `getPlaybackExtras` +/// fills it from a request without `includeStreams` / `checkFiles`. File Info +/// must not serve that lean row: it would render with no stream table and no +/// file-presence flags. +void main() { + const endpoint = '/library/metadata/movie-1'; + final serverId = ServerId('server-1'); + final scopeA = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-a'); + final scopeB = buildPlexProfileScopeId(serverId: serverId, profileId: 'profile-b'); + + late AppDatabase database; + late PlexApiCache cache; + + setUp(() { + database = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(database); + cache = PlexApiCache.instance; + }); + + tearDown(() => database.close()); + + PlexClient clientRecording(List requested, {Map? response, http.Response? failure}) { + final client = testPlexClient( + serverId: serverId, + profileScopeId: scopeA, + handler: (request) async { + final probe = _failoverProbe(request, serverId); + if (probe != null) return probe; + requested.add(request.url); + return failure ?? _jsonResponse(response ?? _fullMetadata); + }, + ); + addTearDown(client.close); + return client; + } + + test('a lean cached metadata row is refetched with the full file-info shape', () async { + await cache.put(scopeA.cacheServerId, endpoint, _leanMetadata); + final requested = []; + final client = clientRecording(requested); + + final info = await client.getFileInfo(_movie()); + + expect(requested, hasLength(1), reason: 'exactly one refetch, not one per part'); + expect(requested.single.queryParameters['includeStreams'], '1'); + expect(requested.single.queryParameters['checkFiles'], '1'); + + final part = info!.versions.single.parts.single; + expect(part.streamsOfKind(MediaStreamKind.video), hasLength(1)); + expect(part.exists, isTrue); + expect(part.accessible, isTrue); + }); + + test('a complete cached row is served without touching the network', () async { + await cache.put(scopeA.cacheServerId, endpoint, _fullMetadata); + final requested = []; + final client = clientRecording(requested); + + final info = await client.getFileInfo(_movie()); + + expect(requested, isEmpty); + expect(info!.versions.single.parts.single.streamsOfKind(MediaStreamKind.video), hasLength(1)); + }); + + test('a part probed as empty still counts as complete', () async { + // `includeStreams=1` on a part with nothing probed yields `Stream: []`. + // That is a complete answer, not a lean row, and must not trigger a + // refetch every time the sheet opens. + await cache.put(scopeA.cacheServerId, endpoint, _probedEmptyMetadata); + final requested = []; + final client = clientRecording(requested); + + final info = await client.getFileInfo(_movie()); + + expect(requested, isEmpty); + expect(info!.versions.single.parts.single.streams, isEmpty); + }); + + test('one complete part does not excuse a lean sibling', () async { + await cache.put(scopeA.cacheServerId, endpoint, _mixedPartsMetadata); + final requested = []; + final client = clientRecording(requested); + + await client.getFileInfo(_movie()); + + expect(requested, hasLength(1)); + }); + + test('offline mode keeps the lean row rather than failing', () async { + await cache.put(scopeA.cacheServerId, endpoint, _leanMetadata); + final requested = []; + final client = clientRecording(requested); + client.setOfflineMode(true); + + final info = await client.getFileInfo(_movie()); + + expect(requested, isEmpty); + expect(info!.versions.single.parts.single.filePath, '/media/movie.mkv'); + expect(info.versions.single.parts.single.streams, isEmpty); + }); + + test('a failed refetch falls back to the lean row instead of returning nothing', () async { + await cache.put(scopeA.cacheServerId, endpoint, _leanMetadata); + final requested = []; + final client = clientRecording(requested, failure: http.Response('boom', 500)); + + final info = await client.getFileInfo(_movie()); + + expect(requested, isNotEmpty); + expect(info, isNotNull); + expect(info!.versions.single.parts.single.filePath, '/media/movie.mkv'); + }); + + test('the refetch keeps the profile identity captured before the cache read', () async { + await cache.put(scopeA.cacheServerId, endpoint, _leanMetadata); + + final tokens = []; + final client = testPlexClient( + token: 'token-a', + serverId: serverId, + profileScopeId: scopeA, + handler: (request) async { + final probe = _failoverProbe(request, serverId); + if (probe != null) return probe; + tokens.add(_plexToken(request)); + return _jsonResponse(_fullMetadata); + }, + ); + addTearDown(client.close); + + // Hold the cache read open so the profile switch lands between the + // cache-first lookup and the refetch — the exact window the captured + // request context exists to close. + final releaseCacheRead = Completer(); + final transactionStarted = Completer(); + final heldTransaction = database.transaction(() async { + transactionStarted.complete(); + await releaseCacheRead.future; + }); + await transactionStarted.future; + + final infoFuture = client.getFileInfo(_movie()); + try { + await client.applyProfileUpdate(newToken: 'token-b', newProfileScopeId: scopeB); + } finally { + releaseCacheRead.complete(); + } + await heldTransaction; + final info = await infoFuture; + + expect(tokens, ['token-a']); + expect(info!.versions.single.parts.single.exists, isTrue); + expect(await cache.get(scopeA.cacheServerId, endpoint), _fullMetadata); + expect(await cache.get(scopeB.cacheServerId, endpoint), isNull); + }); +} + +MediaItem _movie() => testMediaItem(id: 'movie-1', title: 'Movie', serverId: 'server-1'); + +/// The failover client probes server identity before the first real request. +http.Response? _failoverProbe(http.Request request, ServerId serverId) { + if (request.url.path == '/') { + return _jsonResponse({ + 'MediaContainer': {'machineIdentifier': serverId.value}, + }); + } + if (request.url.path == '/media/providers') { + return _jsonResponse({ + 'MediaContainer': {'MediaProvider': []}, + }); + } + return null; +} + +String? _plexToken(http.Request request) { + for (final entry in request.headers.entries) { + if (entry.key.toLowerCase() == 'x-plex-token') return entry.value; + } + return null; +} + +http.Response _jsonResponse(Map body) => + http.Response(jsonEncode(body), 200, headers: const {'content-type': 'application/json'}); + +Map _metadata(List> parts) => { + 'MediaContainer': { + 'Metadata': [ + { + 'ratingKey': 'movie-1', + 'title': 'Movie', + 'type': 'movie', + 'Media': [ + {'id': 1, 'container': 'mkv', 'videoCodec': 'h264', 'Part': parts}, + ], + }, + ], + }, +}; + +/// What `getPlaybackExtras` caches: no `Stream`, no `exists`/`accessible`. +final _leanMetadata = _metadata([ + {'id': 10, 'file': '/media/movie.mkv', 'size': 100}, +]); + +final _fullMetadata = _metadata([ + { + 'id': 10, + 'file': '/media/movie.mkv', + 'size': 100, + 'exists': true, + 'accessible': true, + 'Stream': [ + {'id': 1, 'streamType': 1, 'codec': 'h264'}, + ], + }, +]); + +final _probedEmptyMetadata = _metadata([ + {'id': 10, 'file': '/media/movie.mkv', 'size': 100, 'exists': true, 'accessible': true, 'Stream': []}, +]); + +final _mixedPartsMetadata = _metadata([ + { + 'id': 10, + 'file': '/media/movie-cd1.mkv', + 'exists': true, + 'accessible': true, + 'Stream': [ + {'id': 1, 'streamType': 1, 'codec': 'h264'}, + ], + }, + {'id': 11, 'file': '/media/movie-cd2.mkv'}, +]); diff --git a/test/services/plex_playback_mapper_test.dart b/test/services/plex_playback_mapper_test.dart index 1b15647c..a42cce5f 100644 --- a/test/services/plex_playback_mapper_test.dart +++ b/test/services/plex_playback_mapper_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_file_info.dart'; import 'package:plezy/services/plex_playback_mapper.dart'; void main() { @@ -421,20 +422,32 @@ void main() { ], }); - expect(info?.container, 'mkv'); - expect(info?.videoCodec, 'h264'); - expect(info?.filePath, '/media/movie.mkv'); - expect(info?.fileSize, 123456); - expect(info?.optimizedForStreaming, isTrue); - expect(info?.has64bitOffsets, isFalse); - expect(info?.frameRate, 24); - expect(info?.bitDepth, 8); - expect(info?.audioTracks.single.id, 301); - expect(info?.audioTracks.single.selected, isTrue); - expect(info?.subtitleTracks.single.key, '/subtitles/401'); + final version = info!.versions.single; + final part = version.parts.single; + expect(version.container, 'mkv'); + expect(version.videoCodec, 'h264'); + expect(part.filePath, '/media/movie.mkv'); + expect(part.fileSize, 123456); + expect(version.optimizedForStreaming, isTrue); + expect(version.has64bitOffsets, isFalse); + + final video = part.streamsOfKind(MediaStreamKind.video).single; + expect(video.frameRate, 24); + expect(video.bitDepth, 8); + expect(video.colorSpace, 'bt709'); + + final audio = part.streamsOfKind(MediaStreamKind.audio).single; + expect(audio.id, '301'); + expect(audio.isSelected, isTrue); + expect(audio.channelLayout, 'stereo'); + + expect(part.streamsOfKind(MediaStreamKind.subtitle).single.id, '401'); }); - test('skips unidentifiable subtitle streams in file info', () { + test('keeps subtitle streams that the playback reader would reject', () { + // The playback path needs a numeric stream id and drops the rest; the + // file-info view is purely descriptive, so embedded caption tracks with + // no usable id still belong in the table. final info = parsePlexFileInfoFromJson({ 'Media': [ { @@ -455,8 +468,11 @@ void main() { }); expect(info, isNotNull); - expect(info!.filePath, '/media/movie.mp4'); - expect(info.subtitleTracks.map((track) => track.id), [402]); + final part = info!.versions.single.parts.single; + expect(part.filePath, '/media/movie.mp4'); + final subtitles = part.streamsOfKind(MediaStreamKind.subtitle).toList(); + expect(subtitles.map((stream) => stream.id), [null, 'cea-608', '402']); + expect(subtitles.map((stream) => stream.ordinal), [1, 2, 3]); }); }); } diff --git a/test/widgets/file_info_bottom_sheet_test.dart b/test/widgets/file_info_bottom_sheet_test.dart new file mode 100644 index 00000000..dbe26a16 --- /dev/null +++ b/test/widgets/file_info_bottom_sheet_test.dart @@ -0,0 +1,341 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/focusable_wrapper.dart'; +import 'package:plezy/focus/input_mode_tracker.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/media/media_file_info.dart'; +import 'package:plezy/widgets/file_info_bottom_sheet.dart'; + +import '../test_helpers/theme.dart'; + +void main() { + setUpAll(() => LocaleSettings.setLocaleSync(AppLocale.en)); + + testWidgets('single file renders the item title and every populated media section', (tester) async { + const fileInfo = MediaFileInfo( + versions: [ + MediaFileVersion( + container: 'mkv', + parts: [ + MediaFilePart( + filePath: '/media/Example Movie.mkv', + fileSize: 1048576, + streams: [ + MediaStreamDetails(kind: MediaStreamKind.video, ordinal: 1, codec: 'h264'), + MediaStreamDetails(kind: MediaStreamKind.audio, ordinal: 1, codec: 'aac'), + MediaStreamDetails(kind: MediaStreamKind.subtitle, ordinal: 1, codec: 'srt'), + ], + ), + ], + ), + ], + ); + + await _pumpSheet(tester, fileInfo: fileInfo, title: 'Example Movie'); + + expect(find.text('Example Movie'), findsOneWidget); + expect(find.text('Overview'), findsOneWidget); + expect(find.text('File'), findsOneWidget); + expect(find.text('Video'), findsOneWidget); + expect(find.text('Audio'), findsOneWidget); + expect(find.text('Subtitles'), findsOneWidget); + }); + + testWidgets('absent fields and empty sections are omitted instead of rendering blank labels', (tester) async { + const fileInfo = MediaFileInfo( + versions: [ + MediaFileVersion(parts: [MediaFilePart(container: 'mkv')]), + ], + ); + + await _pumpSheet(tester, fileInfo: fileInfo); + + expect(find.text('File'), findsOneWidget); + expect(find.text('Container'), findsOneWidget); + expect(find.text('Path'), findsNothing); + expect(find.text('Size'), findsNothing); + expect(find.text('Overview'), findsNothing); + expect(find.text('Delivery'), findsNothing); + }); + + testWidgets('version counters and distinct file names render only for multiple versions', (tester) async { + const multiVersion = MediaFileInfo( + versions: [ + MediaFileVersion(parts: [MediaFilePart(filePath: '/media/theatrical.mkv')]), + MediaFileVersion(parts: [MediaFilePart(filePath: '/media/directors-cut.mkv')]), + ], + ); + + await _pumpSheet(tester, fileInfo: multiVersion); + + expect(find.text('Version 1 of 2'), findsOneWidget); + expect(find.text('Version 2 of 2'), findsOneWidget); + expect(find.text('theatrical.mkv'), findsOneWidget); + expect(find.text('directors-cut.mkv'), findsOneWidget); + + const singleVersion = MediaFileInfo( + versions: [ + MediaFileVersion(parts: [MediaFilePart(filePath: '/media/theatrical.mkv')]), + ], + ); + await _pumpSheet(tester, fileInfo: singleVersion); + + expect(find.textContaining('Version '), findsNothing); + expect(find.text('theatrical.mkv'), findsOneWidget); + }); + + testWidgets('file counters render for multiple parts and collapse to File for one part', (tester) async { + const multiPart = MediaFileInfo( + versions: [ + MediaFileVersion( + parts: [ + MediaFilePart(filePath: '/media/disc-1.mkv'), + MediaFilePart(filePath: '/media/disc-2.mkv'), + ], + ), + ], + ); + + await _pumpSheet(tester, fileInfo: multiPart); + + expect(find.text('File 1 of 2'), findsOneWidget); + expect(find.text('File 2 of 2'), findsOneWidget); + expect(find.text('File'), findsNothing); + + const singlePart = MediaFileInfo( + versions: [ + MediaFileVersion(parts: [MediaFilePart(filePath: '/media/movie.mkv')]), + ], + ); + await _pumpSheet(tester, fileInfo: singlePart); + + expect(find.text('File'), findsOneWidget); + expect(find.textContaining('File 1 of'), findsNothing); + }); + + testWidgets('stream section titles number multiple audio tracks but not a single track', (tester) async { + const multipleAudio = MediaFileInfo( + versions: [ + MediaFileVersion( + parts: [ + MediaFilePart( + streams: [ + MediaStreamDetails(kind: MediaStreamKind.audio, ordinal: 1, codec: 'aac'), + MediaStreamDetails(kind: MediaStreamKind.audio, ordinal: 2, codec: 'ac3'), + ], + ), + ], + ), + ], + ); + + await _pumpSheet(tester, fileInfo: multipleAudio); + + expect(find.text('Audio 1'), findsOneWidget); + expect(find.text('Audio 2'), findsOneWidget); + expect(find.text('Audio'), findsNothing); + + const singleAudio = MediaFileInfo( + versions: [ + MediaFileVersion( + parts: [ + MediaFilePart( + streams: [MediaStreamDetails(kind: MediaStreamKind.audio, ordinal: 1, codec: 'aac')], + ), + ], + ), + ], + ); + await _pumpSheet(tester, fileInfo: singleAudio); + + expect(find.text('Audio'), findsOneWidget); + expect(find.textContaining('Audio 1'), findsNothing); + }); + + testWidgets('subtitle flags render as chips only when enabled', (tester) async { + const flaggedSubtitle = MediaFileInfo( + versions: [ + MediaFileVersion( + parts: [ + MediaFilePart( + streams: [ + MediaStreamDetails( + kind: MediaStreamKind.subtitle, + ordinal: 1, + codec: 'srt', + isDefault: true, + isForced: true, + isHearingImpaired: true, + ), + ], + ), + ], + ), + ], + ); + + await _pumpSheet(tester, fileInfo: flaggedSubtitle); + + expect(find.text('Default'), findsOneWidget); + expect(find.text('Forced'), findsOneWidget); + expect(find.text('Hearing impaired'), findsOneWidget); + + const unflaggedSubtitle = MediaFileInfo( + versions: [ + MediaFileVersion( + parts: [ + MediaFilePart( + streams: [ + MediaStreamDetails( + kind: MediaStreamKind.subtitle, + ordinal: 1, + codec: 'srt', + isDefault: false, + isForced: false, + isHearingImpaired: false, + ), + ], + ), + ], + ), + ], + ); + await _pumpSheet(tester, fileInfo: unflaggedSubtitle); + + expect(find.text('Default'), findsNothing); + expect(find.text('Forced'), findsNothing); + expect(find.text('Hearing impaired'), findsNothing); + }); + + testWidgets('file without stream metadata shows the localized no-streams message', (tester) async { + const fileInfo = MediaFileInfo( + versions: [ + MediaFileVersion( + parts: [MediaFilePart(filePath: '/media/movie.mkv', streams: [])], + ), + ], + ); + + await _pumpSheet(tester, fileInfo: fileInfo); + + expect(find.text('The server reported no streams for this file.'), findsOneWidget); + }); + + testWidgets('attachments section lists every attachment file name', (tester) async { + const fileInfo = MediaFileInfo( + versions: [ + MediaFileVersion( + attachments: [ + MediaFileAttachment(index: 0, fileName: 'OpenSans.ttf', mimeType: 'font/ttf'), + MediaFileAttachment(index: 1, fileName: 'NotoSans.otf', codec: 'otf'), + ], + ), + ], + ); + + await _pumpSheet(tester, fileInfo: fileInfo); + + expect(find.text('Attachments'), findsOneWidget); + expect(find.text('OpenSans.ttf'), findsOneWidget); + expect(find.text('NotoSans.otf'), findsOneWidget); + }); + + testWidgets('tapping a path copies its full value and confirms the action', (tester) async { + const path = '/media/movies/Example Movie (2026)/Example Movie.mkv'; + final clipboardCalls = _interceptClipboard(); + const fileInfo = MediaFileInfo( + versions: [ + MediaFileVersion(parts: [MediaFilePart(filePath: path)]), + ], + ); + + await _pumpSheet(tester, fileInfo: fileInfo); + await tester.tap(find.text(path)); + await tester.pumpAndSettle(); + + expect(clipboardCalls, hasLength(1)); + expect(clipboardCalls.single.method, 'Clipboard.setData'); + expect(clipboardCalls.single.arguments, {'text': path}); + expect(find.text('File path copied'), findsOneWidget); + }); + + testWidgets('keyboard select activates the focused path row and copies its full value', (tester) async { + const path = '/media/tv/Example Show/Season 01/Episode 01.mkv'; + final clipboardCalls = _interceptClipboard(); + const fileInfo = MediaFileInfo( + versions: [ + MediaFileVersion(parts: [MediaFilePart(filePath: path)]), + ], + ); + + await _pumpSheet(tester, fileInfo: fileInfo); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + final pathWrapper = find.ancestor(of: find.text(path), matching: find.byType(FocusableWrapper)); + expect(pathWrapper, findsOneWidget); + final pathFocus = find.descendant(of: pathWrapper, matching: find.byType(Focus)).first; + final pathFocusNode = tester.widget(pathFocus).focusNode!; + pathFocusNode.requestFocus(); + await tester.pump(); + expect(pathFocusNode.hasFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.pumpAndSettle(); + + expect(clipboardCalls, hasLength(1)); + expect(clipboardCalls.single.arguments, {'text': path}); + expect(find.text('File path copied'), findsOneWidget); + }); + + testWidgets('field grid uses two columns when wide and one column when narrow', (tester) async { + const fileInfo = MediaFileInfo(versions: [MediaFileVersion(container: 'mkv', durationMs: 123000)]); + + await _pumpSheet(tester, fileInfo: fileInfo, size: const Size(1200, 800)); + final wideContainerDx = tester.getTopLeft(find.text('Container')).dx; + final wideDurationDx = tester.getTopLeft(find.text('Duration')).dx; + expect(wideDurationDx, greaterThan(wideContainerDx)); + + await _pumpSheet(tester, fileInfo: fileInfo, size: const Size(380, 800)); + final narrowContainerDx = tester.getTopLeft(find.text('Container')).dx; + final narrowDurationDx = tester.getTopLeft(find.text('Duration')).dx; + expect(narrowDurationDx, moreOrLessEquals(narrowContainerDx)); + }); +} + +Future _pumpSheet( + WidgetTester tester, { + required MediaFileInfo fileInfo, + String title = '', + Size size = const Size(800, 3000), +}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp( + theme: ThemeData(extensions: const [testMonoTokens]), + home: InputModeTracker( + child: Scaffold( + body: FileInfoBottomSheet(fileInfo: fileInfo, title: title), + ), + ), + ), + ), + ); + await tester.pump(); +} + +List _interceptClipboard() { + final calls = []; + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') calls.add(call); + return null; + }); + addTearDown(() => messenger.setMockMethodCallHandler(SystemChannels.platform, null)); + return calls; +}