Label local-file video quality from mpv track info instead of Unknown

Streams built for local folder files carry no resolution/codec/fps
metadata (unknowable before demux), so the quality selector labeled
them "Unknown". Use the video track mpv reports (demux-w/h/fps, codec)
to fill those fields into a display-only copy of the stream — the
Video row and summary now show e.g. "720p · 30fps" with a codec badge.
Selection and tap handling keep using the original stream.
This commit is contained in:
Arkadiusz Fal
2026-07-29 22:50:50 +02:00
parent 67ed8c322b
commit 9e546e29ee
10 changed files with 69 additions and 3 deletions

View File

@@ -31,6 +31,9 @@ struct MPVTrack: Equatable, Sendable, Identifiable, Decodable {
let codec: String? let codec: String?
let channelCount: Int? let channelCount: Int?
let sampleRate: Int? let sampleRate: Int?
let width: Int?
let height: Int?
let fps: Double?
/// Identifiable across types mpv ids collide between audio/sub/video. /// Identifiable across types mpv ids collide between audio/sub/video.
var id: String { "\(type.rawValue):\(trackID)" } var id: String { "\(type.rawValue):\(trackID)" }
@@ -48,6 +51,9 @@ struct MPVTrack: Equatable, Sendable, Identifiable, Decodable {
case codec case codec
case channelCount = "demux-channel-count" case channelCount = "demux-channel-count"
case sampleRate = "demux-samplerate" case sampleRate = "demux-samplerate"
case width = "demux-w"
case height = "demux-h"
case fps = "demux-fps"
} }
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
@@ -64,6 +70,9 @@ struct MPVTrack: Equatable, Sendable, Identifiable, Decodable {
codec = try container.decodeIfPresent(String.self, forKey: .codec) codec = try container.decodeIfPresent(String.self, forKey: .codec)
channelCount = try container.decodeIfPresent(Int.self, forKey: .channelCount) channelCount = try container.decodeIfPresent(Int.self, forKey: .channelCount)
sampleRate = try container.decodeIfPresent(Int.self, forKey: .sampleRate) sampleRate = try container.decodeIfPresent(Int.self, forKey: .sampleRate)
width = try container.decodeIfPresent(Int.self, forKey: .width)
height = try container.decodeIfPresent(Int.self, forKey: .height)
fps = try container.decodeIfPresent(Double.self, forKey: .fps)
} }
init( init(
@@ -78,7 +87,10 @@ struct MPVTrack: Equatable, Sendable, Identifiable, Decodable {
isAlbumArt: Bool = false, isAlbumArt: Bool = false,
codec: String? = nil, codec: String? = nil,
channelCount: Int? = nil, channelCount: Int? = nil,
sampleRate: Int? = nil sampleRate: Int? = nil,
width: Int? = nil,
height: Int? = nil,
fps: Double? = nil
) { ) {
self.trackID = trackID self.trackID = trackID
self.type = type self.type = type
@@ -92,6 +104,9 @@ struct MPVTrack: Equatable, Sendable, Identifiable, Decodable {
self.codec = codec self.codec = codec
self.channelCount = channelCount self.channelCount = channelCount
self.sampleRate = sampleRate self.sampleRate = sampleRate
self.width = width
self.height = height
self.fps = fps
} }
/// Base language code normalized to the 2-letter form when possible, so /// Base language code normalized to the 2-letter form when possible, so

View File

@@ -85,6 +85,13 @@ final class PlayerService {
embeddedSubtitleTracks.first(where: \.isSelected)?.trackID embeddedSubtitleTracks.first(where: \.isSelected)?.trackID
} }
/// The video track mpv currently plays. Used to label local-file streams,
/// whose Stream carries no resolution/codec/fps metadata before demux.
var primaryEmbeddedVideoTrack: MPVTrack? {
let videoTracks = embeddedTracks.filter { $0.type == .video && !$0.isExternal && !$0.isAlbumArt }
return videoTracks.first(where: \.isSelected) ?? videoTracks.first
}
/// Embedded-track picks that should survive same-video reloads /// Embedded-track picks that should survive same-video reloads
/// (quality switch, audio-mode toggle, buffer-stall retry). /// (quality switch, audio-mode toggle, buffer-stall retry).
private var desiredEmbeddedAudioTrackID: Int? private var desiredEmbeddedAudioTrackID: Int?

View File

@@ -866,6 +866,7 @@ extension ExpandedPlayerSheet {
embeddedSubtitleTracks: playerService.embeddedSubtitleTracks, embeddedSubtitleTracks: playerService.embeddedSubtitleTracks,
currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID, currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID,
currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID, currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID,
embeddedVideoTrack: playerService.primaryEmbeddedVideoTrack,
onEmbeddedAudioTrackSelected: { trackID in onEmbeddedAudioTrackSelected: { trackID in
playerService.selectEmbeddedAudioTrack(trackID) playerService.selectEmbeddedAudioTrack(trackID)
}, },
@@ -1146,6 +1147,7 @@ extension ExpandedPlayerSheet {
embeddedSubtitleTracks: playerService.embeddedSubtitleTracks, embeddedSubtitleTracks: playerService.embeddedSubtitleTracks,
currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID, currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID,
currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID, currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID,
embeddedVideoTrack: playerService.primaryEmbeddedVideoTrack,
onEmbeddedAudioTrackSelected: { trackID in onEmbeddedAudioTrackSelected: { trackID in
playerService.selectEmbeddedAudioTrack(trackID) playerService.selectEmbeddedAudioTrack(trackID)
}, },
@@ -1533,6 +1535,7 @@ extension ExpandedPlayerSheet {
embeddedSubtitleTracks: playerService.embeddedSubtitleTracks, embeddedSubtitleTracks: playerService.embeddedSubtitleTracks,
currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID, currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID,
currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID, currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID,
embeddedVideoTrack: playerService.primaryEmbeddedVideoTrack,
onEmbeddedAudioTrackSelected: { trackID in onEmbeddedAudioTrackSelected: { trackID in
playerService.selectEmbeddedAudioTrack(trackID) playerService.selectEmbeddedAudioTrack(trackID)
}, },

View File

@@ -701,6 +701,7 @@ private struct PlayerSheetsModifier: ViewModifier {
embeddedSubtitleTracks: playerService.embeddedSubtitleTracks, embeddedSubtitleTracks: playerService.embeddedSubtitleTracks,
currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID, currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID,
currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID, currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID,
embeddedVideoTrack: playerService.primaryEmbeddedVideoTrack,
onStreamSelected: { stream, audioStream in onStreamSelected: { stream, audioStream in
onStreamSelected(stream, audioStream) onStreamSelected(stream, audioStream)
}, },

View File

@@ -266,6 +266,7 @@ struct MPVVideoView: View {
embeddedSubtitleTracks: playerService.embeddedSubtitleTracks, embeddedSubtitleTracks: playerService.embeddedSubtitleTracks,
currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID, currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID,
currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID, currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID,
embeddedVideoTrack: playerService.primaryEmbeddedVideoTrack,
onEmbeddedAudioTrackSelected: { trackID in onEmbeddedAudioTrackSelected: { trackID in
playerService.selectEmbeddedAudioTrack(trackID) playerService.selectEmbeddedAudioTrack(trackID)
}, },

View File

@@ -96,6 +96,7 @@ struct PlayerControlsView: View {
var embeddedSubtitleTracks: [MPVTrack] = [] var embeddedSubtitleTracks: [MPVTrack] = []
var currentEmbeddedAudioTrackID: Int? = nil var currentEmbeddedAudioTrackID: Int? = nil
var currentEmbeddedSubtitleTrackID: Int? = nil var currentEmbeddedSubtitleTrackID: Int? = nil
var embeddedVideoTrack: MPVTrack? = nil
/// Callback when an embedded audio track is selected /// Callback when an embedded audio track is selected
var onEmbeddedAudioTrackSelected: ((Int) -> Void)? = nil var onEmbeddedAudioTrackSelected: ((Int) -> Void)? = nil
/// Callback when an embedded subtitle track is selected (nil = off) /// Callback when an embedded subtitle track is selected (nil = off)
@@ -315,6 +316,7 @@ struct PlayerControlsView: View {
currentAudioStream: currentAudioStream, currentAudioStream: currentAudioStream,
initialTab: .video, initialTab: .video,
showTabPicker: false, showTabPicker: false,
embeddedVideoTrack: embeddedVideoTrack,
onStreamSelected: { stream, audioStream in onStreamSelected: { stream, audioStream in
onStreamSelected?(stream, audioStream) onStreamSelected?(stream, audioStream)
}, },

View File

@@ -184,7 +184,7 @@ extension QualitySelectorView {
if format == .hls || format == .dash { if format == .hls || format == .dash {
return format == .hls ? "HLS" : "DASH" return format == .hls ? "HLS" : "DASH"
} }
return stream.qualityLabel return displayStream(for: stream).qualityLabel
} }
private var currentAudioDisplayValue: String { private var currentAudioDisplayValue: String {
@@ -695,6 +695,35 @@ extension QualitySelectorView {
} }
} }
/// A local file's Stream carries no resolution/codec/fps metadata
/// (`MediaFile.toStream` cannot know them before demux), which would label
/// the row "Unknown". Once mpv reports the file's video track, fill those
/// fields for display. The enriched copy is display-only selection and
/// tap handling keep using the original stream (compared by URL).
func displayStream(for stream: Stream) -> Stream {
guard stream.url.isFileURL,
stream.resolution == nil,
!stream.isAudioOnly,
let track = embeddedVideoTrack,
let width = track.width,
let height = track.height else {
return stream
}
return Stream(
url: stream.url,
resolution: StreamResolution(width: width, height: height),
format: stream.format,
videoCodec: track.codec ?? stream.videoCodec,
audioCodec: stream.audioCodec,
bitrate: stream.bitrate,
fileSize: stream.fileSize,
isLive: stream.isLive,
mimeType: stream.mimeType,
httpHeaders: stream.httpHeaders,
fps: track.fps.map { Int($0.rounded()) }
)
}
@ViewBuilder @ViewBuilder
private func videoStreamRow(_ stream: Stream) -> some View { private func videoStreamRow(_ stream: Stream) -> some View {
let isDownloadedStream: Bool = stream.url.isFileURL let isDownloadedStream: Bool = stream.url.isFileURL
@@ -704,7 +733,7 @@ extension QualitySelectorView {
let isPreferredQuality: Bool = stream.resolution == preferredQuality.maxResolution let isPreferredQuality: Bool = stream.resolution == preferredQuality.maxResolution
VideoStreamRowView( VideoStreamRowView(
stream: stream, stream: displayStream(for: stream),
isSelected: isSelected, isSelected: isSelected,
isPreferredQuality: isPreferredQuality, isPreferredQuality: isPreferredQuality,
isDownloaded: isDownloadedStream, isDownloaded: isDownloadedStream,

View File

@@ -34,6 +34,8 @@ struct QualitySelectorView: View {
let embeddedSubtitleTracks: [MPVTrack] let embeddedSubtitleTracks: [MPVTrack]
let currentEmbeddedAudioTrackID: Int? let currentEmbeddedAudioTrackID: Int?
let currentEmbeddedSubtitleTrackID: Int? let currentEmbeddedSubtitleTrackID: Int?
/// The playing file's video track, used to label metadata-less local streams
let embeddedVideoTrack: MPVTrack?
let onEmbeddedAudioTrackSelected: (Int) -> Void let onEmbeddedAudioTrackSelected: (Int) -> Void
/// nil = subtitles off /// nil = subtitles off
let onEmbeddedSubtitleTrackSelected: (Int?) -> Void let onEmbeddedSubtitleTrackSelected: (Int?) -> Void
@@ -181,6 +183,7 @@ struct QualitySelectorView: View {
embeddedSubtitleTracks: [MPVTrack] = [], embeddedSubtitleTracks: [MPVTrack] = [],
currentEmbeddedAudioTrackID: Int? = nil, currentEmbeddedAudioTrackID: Int? = nil,
currentEmbeddedSubtitleTrackID: Int? = nil, currentEmbeddedSubtitleTrackID: Int? = nil,
embeddedVideoTrack: MPVTrack? = nil,
onStreamSelected: @escaping (Stream, Stream?) -> Void, onStreamSelected: @escaping (Stream, Stream?) -> Void,
onCaptionSelected: @escaping (Caption?) -> Void = { _ in }, onCaptionSelected: @escaping (Caption?) -> Void = { _ in },
onLoadOnlineStreams: @escaping () -> Void = {}, onLoadOnlineStreams: @escaping () -> Void = {},
@@ -210,6 +213,7 @@ struct QualitySelectorView: View {
self.embeddedSubtitleTracks = embeddedSubtitleTracks self.embeddedSubtitleTracks = embeddedSubtitleTracks
self.currentEmbeddedAudioTrackID = currentEmbeddedAudioTrackID self.currentEmbeddedAudioTrackID = currentEmbeddedAudioTrackID
self.currentEmbeddedSubtitleTrackID = currentEmbeddedSubtitleTrackID self.currentEmbeddedSubtitleTrackID = currentEmbeddedSubtitleTrackID
self.embeddedVideoTrack = embeddedVideoTrack
self.onStreamSelected = onStreamSelected self.onStreamSelected = onStreamSelected
self.onCaptionSelected = onCaptionSelected self.onCaptionSelected = onCaptionSelected
self.onLoadOnlineStreams = onLoadOnlineStreams self.onLoadOnlineStreams = onLoadOnlineStreams

View File

@@ -171,6 +171,7 @@ struct TVPlayerView: View {
embeddedSubtitleTracks: playerService.embeddedSubtitleTracks, embeddedSubtitleTracks: playerService.embeddedSubtitleTracks,
currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID, currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID,
currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID, currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID,
embeddedVideoTrack: playerService.primaryEmbeddedVideoTrack,
onStreamSelected: { stream, audioStream in onStreamSelected: { stream, audioStream in
switchToStream(stream, audioStream: audioStream) switchToStream(stream, audioStream: audioStream)
}, },

View File

@@ -42,6 +42,9 @@ struct MPVTrackDecodingTests {
#expect(video.trackID == 1) #expect(video.trackID == 1)
#expect(video.isDefault) #expect(video.isDefault)
#expect(video.isSelected) #expect(video.isSelected)
#expect(video.width == 3840)
#expect(video.height == 1600)
#expect(video.fps == 23.976)
let mainAudio = tracks[1] let mainAudio = tracks[1]
#expect(mainAudio.type == .audio) #expect(mainAudio.type == .audio)