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; +}