Renames Sources/PlumTV→TVAnarchy and PlumTVCore→TVAnarchyCore (the rename the auto-commit service couldn't stage — it git-add'd the old, now-gone paths and aborted every cycle), and commits the accumulated work: - Library: black-built index fast path (LibraryIndex + scanFromIndex) with NFS-walk fallback; incremental --add on download-complete; mtime staleness gate; loose-file series-collapse fix; determinate scan/index progress. - Cover art: keyless TVmaze cartoon-vs-live-action disambiguation (type/year). - Player: sleep timer (timed + end-of-episode); visibility-gated polling. - Home: Continue Watching cover art + live refresh; Recently Added; adult gate. - Logs: multi-line selection + copy; truncated giant tx-list errors. - Hover previews (opt-in) via black ffmpeg + scp. Also gitignores foreign project trees (governor/mcp/fleet/recommender) that sit in this directory but belong to their own repos. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
72 lines
3.2 KiB
Swift
72 lines
3.2 KiB
Swift
import Foundation
|
|
|
|
/// Audio vs subtitle track.
|
|
public enum TrackKind: String, Sendable, Equatable { case audio, subtitle }
|
|
|
|
/// One selectable track on the currently-playing file. `id` is the backend's
|
|
/// track id (mpv aid/sid, VLC es id).
|
|
public struct MediaTrack: Identifiable, Sendable, Equatable {
|
|
public let id: Int
|
|
public let kind: TrackKind
|
|
public let lang: String?
|
|
public let title: String?
|
|
public let codec: String?
|
|
public let selected: Bool
|
|
|
|
public init(id: Int, kind: TrackKind, lang: String? = nil, title: String? = nil,
|
|
codec: String? = nil, selected: Bool = false) {
|
|
self.id = id; self.kind = kind; self.lang = lang
|
|
self.title = title; self.codec = codec; self.selected = selected
|
|
}
|
|
|
|
/// Human label for a menu row, e.g. "JPN · Dialogue", "ENG", "Track 2".
|
|
public var label: String {
|
|
var parts: [String] = []
|
|
if let lang, !lang.isEmpty { parts.append(lang.uppercased()) }
|
|
if let title, !title.isEmpty { parts.append(title) }
|
|
else if let codec, !codec.isEmpty { parts.append(codec.uppercased()) }
|
|
let s = parts.joined(separator: " · ")
|
|
return s.isEmpty ? "Track \(id)" : s
|
|
}
|
|
}
|
|
|
|
/// Per-series audio/subtitle intent. `sub` = original audio + subtitles on (the
|
|
/// common anime case); `dub` = native-language audio + subtitles off.
|
|
public enum DubSub: String, Sendable, Codable, CaseIterable, Identifiable {
|
|
case sub, dub
|
|
public var id: String { rawValue }
|
|
public var label: String { self == .sub ? "Sub" : "Dub" }
|
|
|
|
/// Preferred audio languages (mpv alang order). For `sub` we want the original
|
|
/// (Japanese) audio; for `dub` the English track. mpv falls back to the only
|
|
/// available track when none match, so English-native content stays correct.
|
|
public var audioLangs: [String] {
|
|
switch self {
|
|
case .sub: return ["jpn", "ja", "jp"]
|
|
case .dub: return ["eng", "en"]
|
|
}
|
|
}
|
|
/// Preferred subtitle languages (mpv slang order).
|
|
public var subLangs: [String] { ["eng", "en"] }
|
|
/// Whether subtitles should be shown at all.
|
|
public var subsEnabled: Bool { self == .sub }
|
|
}
|
|
|
|
/// A target that can enumerate and select audio/subtitle tracks. Language
|
|
/// preference persists across files (mpv `alang`/`slang`, applied to every file
|
|
/// the playlist loads next); explicit track ids are per-file.
|
|
public protocol TrackSelectable: AnyObject {
|
|
func tracks() async -> [MediaTrack]
|
|
func setAudioTrack(_ id: Int) async
|
|
func setSubtitleTrack(_ id: Int?) async // nil = off
|
|
/// Persist a language preference (carries to subsequent files) and best-effort
|
|
/// select matching tracks on the currently-loaded file.
|
|
func applyLanguagePreference(audioLangs: [String], subLangs: [String], subsEnabled: Bool) async
|
|
}
|
|
|
|
/// True when `lang` matches any of `wanted` (case-insensitive prefix, so "jpn"
|
|
/// matches "jp"/"jpn"/"ja" loosely). Shared by the mpv/VLC track matchers.
|
|
public func trackLangMatches(_ lang: String?, _ wanted: [String]) -> Bool {
|
|
guard let lang = lang?.lowercased(), !lang.isEmpty else { return false }
|
|
return wanted.contains { lang.hasPrefix($0) || $0.hasPrefix(lang) }
|
|
}
|