Customizable Device Profiles (#1169)

* Rename ExperimentalSettingsView.swift to PlaybackQualitySettingsView.swift

Fix Merge

* Rename MaximumBitrateSettingsView.swift to PlaybackQualitySettingsView.swift

fix merge

* Re-implement on Main. Should now have all the Main changed. Added a new change to use the Device Profile as a Transcoding Profile.

* Part 1 -> Making VideoPlayerType into a struct (I Hope) correctly

* Part 1.1 -> Making VideoPlayerType into a struct (I Hope) correctly

* Remove unneeded Files

* Missing file + CustomDeviceProfileSelection -> CustomDeviceProfileAction Rename

* Change + to Appending

* Attempt to add StorageValues+User. Not sure if this is correct?

* Move the Array unwrapping to funcitons. Not required but this should help prevent accidently doing this wrong. Add subtitles back into the custom profiles since that somehow got dropped. Added a PlaybackCompatibility enum. This might need to work for more than just video

* Complete rewrite to allow multiple profiles, compatibility mode, and directplay.

* Hardward -> Hardware

* Update CustomDeviceProfileSettingsView.swift

Double Licensing

* It was actually really easy to implement iOS... Trash cans still look weird and small.

* Swipe to Delete instead of the edit button

* wip

* wip

* Linting

* tvOS Implementation

* wip

* wip

* cleanup

* Create Package.resolved

---------

Co-authored-by: Joseph Kribs <joseph@kribs.net>
Co-authored-by: Ethan Pippin <ethanpippin2343@gmail.com>
This commit is contained in:
Joe 2024-09-02 15:33:02 -06:00 committed by GitHub
parent 58dfddeeca
commit f5bd1b8fcd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 2844 additions and 551 deletions

View File

@ -0,0 +1,45 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Stinsen
import SwiftUI
final class CustomDeviceProfileCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \CustomDeviceProfileCoordinator.start)
@Root
var start = makeStart
@Route(.push)
var customDeviceProfileSettings = makeCustomDeviceProfileSettings
@Route(.push)
var editCustomDeviceProfile = makeEditCustomDeviceProfile
@Route(.push)
var createCustomDeviceProfile = makeCreateCustomDeviceProfile
func makeCustomDeviceProfileSettings() -> NavigationViewCoordinator<PlaybackQualitySettingsCoordinator> {
NavigationViewCoordinator(
PlaybackQualitySettingsCoordinator()
)
}
func makeEditCustomDeviceProfile(profile: Binding<CustomDeviceProfile>)
-> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator> {
NavigationViewCoordinator(EditCustomDeviceProfileCoordinator(profile: profile))
}
func makeCreateCustomDeviceProfile() -> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator> {
NavigationViewCoordinator(EditCustomDeviceProfileCoordinator())
}
@ViewBuilder
func makeStart() -> some View {
CustomDeviceProfileSettingsView()
}
}

View File

@ -0,0 +1,57 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Stinsen
import SwiftUI
final class EditCustomDeviceProfileCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \EditCustomDeviceProfileCoordinator.start)
@Root
var start = makeStart
// TODO: fix for tvOS
@Route(.push)
var customDeviceAudioEditor = makeCustomDeviceAudioEditor
@Route(.push)
var customDeviceVideoEditor = makeCustomDeviceVideoEditor
@Route(.push)
var customDeviceContainerEditor = makeCustomDeviceContainerEditor
private let profile: Binding<CustomDeviceProfile>?
init(profile: Binding<CustomDeviceProfile>? = nil) {
self.profile = profile
}
@ViewBuilder
func makeCustomDeviceAudioEditor(selection: Binding<[AudioCodec]>) -> some View {
OrderedSectionSelectorView(selection: selection, sources: AudioCodec.allCases)
.navigationTitle(L10n.audio)
}
@ViewBuilder
func makeCustomDeviceVideoEditor(selection: Binding<[VideoCodec]>) -> some View {
OrderedSectionSelectorView(selection: selection, sources: VideoCodec.allCases)
.navigationTitle(L10n.video)
}
@ViewBuilder
func makeCustomDeviceContainerEditor(selection: Binding<[MediaContainer]>) -> some View {
OrderedSectionSelectorView(selection: selection, sources: MediaContainer.allCases)
.navigationTitle(L10n.containers)
}
@ViewBuilder
func makeStart() -> some View {
CustomDeviceProfileSettingsView.EditCustomDeviceProfileView(profile: profile)
.navigationTitle(L10n.customProfile)
}
}

View File

@ -0,0 +1,41 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Stinsen
import SwiftUI
final class PlaybackQualitySettingsCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \PlaybackQualitySettingsCoordinator.start)
@Root
var start = makeStart
@Route(.push)
var customDeviceProfileSettings = makeCustomDeviceProfileSettings
func makeCustomDeviceProfileSettings() -> NavigationViewCoordinator<CustomDeviceProfileCoordinator> {
NavigationViewCoordinator(
CustomDeviceProfileCoordinator()
)
}
func makeEditCustomDeviceProfile(profile: Binding<CustomDeviceProfile>)
-> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator> {
NavigationViewCoordinator(EditCustomDeviceProfileCoordinator(profile: profile))
}
func makeCreateCustomDeviceProfile() -> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator> {
NavigationViewCoordinator(EditCustomDeviceProfileCoordinator())
}
@ViewBuilder
func makeStart() -> some View {
PlaybackQualitySettingsView()
}
}

View File

@ -22,7 +22,7 @@ final class SettingsCoordinator: NavigationCoordinatable {
@Route(.push)
var nativePlayerSettings = makeNativePlayerSettings
@Route(.push)
var maximumBitrateSettings = makeMaximumBitrateSettings
var playbackQualitySettings = makePlaybackQualitySettings
@Route(.push)
var quickConnect = makeQuickConnectAuthorize
@Route(.push)
@ -46,6 +46,13 @@ final class SettingsCoordinator: NavigationCoordinatable {
var serverDetail = makeServerDetail
@Route(.push)
var videoPlayerSettings = makeVideoPlayerSettings
@Route(.push)
var customDeviceProfileSettings = makeCustomDeviceProfileSettings
@Route(.modal)
var editCustomDeviceProfile = makeEditCustomDeviceProfile
@Route(.modal)
var createCustomDeviceProfile = makeCreateCustomDeviceProfile
#if DEBUG
@Route(.push)
@ -59,13 +66,15 @@ final class SettingsCoordinator: NavigationCoordinatable {
@Route(.modal)
var experimentalSettings = makeExperimentalSettings
@Route(.modal)
var indicatorSettings = makeIndicatorSettings
@Route(.modal)
var log = makeLog
@Route(.modal)
var serverDetail = makeServerDetail
@Route(.modal)
var videoPlayerSettings = makeVideoPlayerSettings
@Route(.modal)
var maximumBitrateSettings = makeMaximumBitrateSettings
var playbackQualitySettings = makePlaybackQualitySettings
#endif
#if os(iOS)
@ -75,8 +84,22 @@ final class SettingsCoordinator: NavigationCoordinatable {
}
@ViewBuilder
func makeMaximumBitrateSettings() -> some View {
MaximumBitrateSettingsView()
func makePlaybackQualitySettings() -> some View {
PlaybackQualitySettingsView()
}
@ViewBuilder
func makeCustomDeviceProfileSettings() -> some View {
CustomDeviceProfileSettingsView()
}
func makeEditCustomDeviceProfile(profile: Binding<CustomDeviceProfile>)
-> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator> {
NavigationViewCoordinator(EditCustomDeviceProfileCoordinator(profile: profile))
}
func makeCreateCustomDeviceProfile() -> NavigationViewCoordinator<EditCustomDeviceProfileCoordinator> {
NavigationViewCoordinator(EditCustomDeviceProfileCoordinator())
}
@ViewBuilder
@ -123,6 +146,15 @@ final class SettingsCoordinator: NavigationCoordinatable {
EditServerView(server: server)
}
func makeItemFilterDrawerSelector(selection: Binding<[ItemFilterType]>) -> some View {
OrderedSectionSelectorView(selection: selection, sources: ItemFilterType.allCases)
.navigationTitle(L10n.filters)
}
func makeVideoPlayerSettings() -> VideoPlayerSettingsCoordinator {
VideoPlayerSettingsCoordinator()
}
#if DEBUG
@ViewBuilder
func makeDebugSettings() -> some View {
@ -130,20 +162,15 @@ final class SettingsCoordinator: NavigationCoordinatable {
}
#endif
func makeItemFilterDrawerSelector(selection: Binding<[ItemFilterType]>) -> some View {
OrderedSectionSelectorView(selection: selection, sources: ItemFilterType.allCases)
}
func makeVideoPlayerSettings() -> VideoPlayerSettingsCoordinator {
VideoPlayerSettingsCoordinator()
}
#endif
#if os(tvOS)
func makeCustomizeViewsSettings() -> NavigationViewCoordinator<CustomizeSettingsCoordinator> {
NavigationViewCoordinator(CustomizeSettingsCoordinator())
func makeCustomizeViewsSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator(
BasicNavigationViewCoordinator {
CustomizeViewsSettings()
}
)
}
func makeExperimentalSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
@ -154,6 +181,12 @@ final class SettingsCoordinator: NavigationCoordinatable {
)
}
func makeIndicatorSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
IndicatorSettingsView()
}
}
func makeServerDetail(server: ServerState) -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
EditServerView(server: server)
@ -161,13 +194,15 @@ final class SettingsCoordinator: NavigationCoordinatable {
}
func makeVideoPlayerSettings() -> NavigationViewCoordinator<VideoPlayerSettingsCoordinator> {
NavigationViewCoordinator(VideoPlayerSettingsCoordinator())
NavigationViewCoordinator(
VideoPlayerSettingsCoordinator()
)
}
func makeMaximumBitrateSettings() -> NavigationViewCoordinator<BasicNavigationViewCoordinator> {
NavigationViewCoordinator {
MaximumBitrateSettingsView()
}
func makePlaybackQualitySettings() -> NavigationViewCoordinator<PlaybackQualitySettingsCoordinator> {
NavigationViewCoordinator(
PlaybackQualitySettingsCoordinator()
)
}
#endif

View File

@ -50,3 +50,8 @@ extension Array {
return removeFirst()
}
}
// extension Array where Element: RawRepresentable<String> {
//
// var asCommaString: String {}
// }

View File

@ -13,12 +13,19 @@ import JellyfinAPI
import Logging
extension BaseItemDto {
func videoPlayerViewModel(with mediaSource: MediaSourceInfo) async throws -> VideoPlayerViewModel {
let currentVideoPlayerType = Defaults[.VideoPlayer.videoPlayerType]
let currentVideoBitrate = Defaults[.VideoPlayer.appMaximumBitrate]
let currentVideoBitrate = Defaults[.VideoPlayer.Playback.appMaximumBitrate]
let compatibilityMode = Defaults[.VideoPlayer.Playback.compatibilityMode]
let maxBitrate = try await getMaxBitrate(for: currentVideoBitrate)
let profile = DeviceProfile.build(for: currentVideoPlayerType, maxBitrate: maxBitrate)
let profile = DeviceProfile.build(
for: currentVideoPlayerType,
compatibilityMode: compatibilityMode,
maxBitrate: maxBitrate
)
let userSession = Container.shared.currentUserSession()!
@ -46,14 +53,17 @@ extension BaseItemDto {
}
func liveVideoPlayerViewModel(with mediaSource: MediaSourceInfo, logger: Logger) async throws -> VideoPlayerViewModel {
let currentVideoPlayerType = Defaults[.VideoPlayer.videoPlayerType]
let currentVideoBitrate = Defaults[.VideoPlayer.appMaximumBitrate]
let currentVideoBitrate = Defaults[.VideoPlayer.Playback.appMaximumBitrate]
let compatibilityMode = Defaults[.VideoPlayer.Playback.compatibilityMode]
let maxBitrate = try await getMaxBitrate(for: currentVideoBitrate)
var profile = DeviceProfile.build(for: currentVideoPlayerType, maxBitrate: maxBitrate)
if Defaults[.Experimental.liveTVForceDirectPlay] {
profile.directPlayProfiles = [DirectPlayProfile(type: .video)]
}
let profile = DeviceProfile.build(
for: currentVideoPlayerType,
compatibilityMode: compatibilityMode,
maxBitrate: maxBitrate
)
let userSession = Container.shared.currentUserSession()!
@ -101,7 +111,7 @@ extension BaseItemDto {
}
private func getMaxBitrate(for bitrate: PlaybackBitrate) async throws -> Int {
let settingBitrate = Defaults[.VideoPlayer.appMaximumBitrateTest]
let settingBitrate = Defaults[.VideoPlayer.Playback.appMaximumBitrateTest]
guard bitrate != .auto else {
return try await testBitrate(with: settingBitrate.rawValue)

View File

@ -0,0 +1,32 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
extension CodecProfile {
init(
codec: String? = nil,
container: String? = nil,
type: CodecType? = nil,
@ArrayBuilder<ProfileCondition> applyConditions: () -> [ProfileCondition] = { [] },
@ArrayBuilder<ProfileCondition> conditions: () -> [ProfileCondition] = { [] }
) {
let applyConditions = applyConditions()
let conditions = conditions()
self.init(
applyConditions: applyConditions.isEmpty ? nil : applyConditions,
codec: codec,
conditions: conditions.isEmpty ? nil : conditions,
container: container,
type: type
)
}
}

View File

@ -0,0 +1,79 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Defaults
import JellyfinAPI
extension DeviceProfile {
static func build(
for videoPlayer: VideoPlayerType,
compatibilityMode: PlaybackCompatibility,
maxBitrate: Int? = nil
) -> DeviceProfile {
var deviceProfile: DeviceProfile = .init()
// MARK: - Video Player Specific Logic
deviceProfile.codecProfiles = videoPlayer.codecProfiles
deviceProfile.responseProfiles = videoPlayer.responseProfiles
deviceProfile.subtitleProfiles = videoPlayer.subtitleProfiles
// MARK: - DirectPlay & Transcoding Profiles
switch compatibilityMode {
case .auto:
deviceProfile.directPlayProfiles = videoPlayer.directPlayProfiles
deviceProfile.transcodingProfiles = videoPlayer.transcodingProfiles
case .mostCompatible:
deviceProfile.directPlayProfiles = PlaybackCompatibility.Video.compatibilityDirectPlayProfile
deviceProfile.transcodingProfiles = PlaybackCompatibility.Video.compatibilityTranscodingProfile
case .directPlay:
deviceProfile.directPlayProfiles = PlaybackCompatibility.Video.forcedDirectPlayProfile
case .custom:
let customProfileMode = Defaults[.VideoPlayer.Playback.customDeviceProfileAction]
let playbackDeviceProfile = StoredValues[.User.customDeviceProfiles]
if customProfileMode == .add {
deviceProfile.directPlayProfiles = videoPlayer.directPlayProfiles
deviceProfile.transcodingProfiles = videoPlayer.transcodingProfiles
} else {
deviceProfile.directPlayProfiles = []
// Only clear the Transcoding Profiles if one of the CustomProfiles is active as a Transcoding Profile
if playbackDeviceProfile.contains(where: { $0.useAsTranscodingProfile == true }) {
deviceProfile.transcodingProfiles = []
} else {
deviceProfile.transcodingProfiles = videoPlayer.transcodingProfiles
}
}
for profile in playbackDeviceProfile where profile.type == .video {
deviceProfile.directPlayProfiles?.append(profile.directPlayProfile)
if profile.useAsTranscodingProfile {
deviceProfile.transcodingProfiles?.append(profile.transcodingProfile)
}
}
}
// MARK: - Assign the Bitrate if provided
if let maxBitrate {
deviceProfile.maxStaticBitrate = maxBitrate
deviceProfile.maxStreamingBitrate = maxBitrate
deviceProfile.musicStreamingTranscodingBitrate = maxBitrate
}
return deviceProfile
}
}

View File

@ -1,90 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
extension DeviceProfile {
static func nativeProfile() -> DeviceProfile {
var profile: DeviceProfile = .init()
// Build direct play profiles
profile.directPlayProfiles = [
// Apple limitation: no mp3 in mp4; avi only supports mjpeg with pcm
// Right now, mp4 restrictions can't be enforced because mp4, m4v, mov, 3gp,3g2 treated the same
DirectPlayProfile(
audioCodec: "flac,alac,aac,eac3,ac3,opus",
container: "mp4",
type: .video,
videoCodec: "hevc,h264,mpeg4"
),
DirectPlayProfile(
audioCodec: "alac,aac,ac3",
container: "m4v",
type: .video,
videoCodec: "h264,mpeg4"
),
DirectPlayProfile(
audioCodec: "alac,aac,eac3,ac3,mp3,pcm_s24be,pcm_s24le,pcm_s16be,pcm_s16le",
container: "mov",
type: .video,
videoCodec: "hevc,h264,mpeg4,mjpeg"
),
DirectPlayProfile(
audioCodec: "aac,eac3,ac3,mp3",
container: "mpegts",
type: .video,
videoCodec: "h264"
),
DirectPlayProfile(
audioCodec: "aac,amr_nb",
container: "3gp,3g2",
type: .video,
videoCodec: "h264,mpeg4"
),
DirectPlayProfile(
audioCodec: "pcm_s16le,pcm_mulaw",
container: "avi",
type: .video,
videoCodec: "mjpeg"
),
]
// Build transcoding profiles
profile.transcodingProfiles = [
TranscodingProfile(
audioCodec: "flac,alac,aac,eac3,ac3,opus",
isBreakOnNonKeyFrames: true,
container: "mp4",
context: .streaming,
maxAudioChannels: "8",
minSegments: 2,
protocol: "hls",
type: .video,
videoCodec: "hevc,h264,mpeg4"
),
]
// Create subtitle profiles
profile.subtitleProfiles = [
// FFmpeg can only convert bitmap to bitmap and text to text; burn in bitmap subs
SubtitleProfile(format: "pgssub", method: .encode),
SubtitleProfile(format: "dvdsub", method: .encode),
SubtitleProfile(format: "dvbsub", method: .encode),
SubtitleProfile(format: "xsub", method: .encode),
// According to Apple HLS authoring specs, WebVTT must be in a text file delivered via HLS
SubtitleProfile(format: "vtt", method: .hls), // webvtt
// Apple HLS authoring spec has closed captions in video segments and TTML in fmp4
SubtitleProfile(format: "ttml", method: .embed),
SubtitleProfile(format: "cc_dec", method: .embed),
]
return profile
}
}

View File

@ -1,78 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
extension DeviceProfile {
// For now, assume native and VLCKit support same codec conditions
static func sharedCodecProfiles() -> [CodecProfile] {
var codecProfiles: [CodecProfile] = []
let h264CodecConditions: [ProfileCondition] = [
ProfileCondition(
condition: .notEquals,
isRequired: false,
property: .isAnamorphic,
value: "true"
),
ProfileCondition(
condition: .equalsAny,
isRequired: false,
property: .videoProfile,
value: "high|main|baseline|constrained baseline"
),
ProfileCondition(
condition: .lessThanEqual,
isRequired: false,
property: .videoLevel,
value: "80"
),
ProfileCondition(
condition: .notEquals,
isRequired: false,
property: .isInterlaced,
value: "true"
),
]
codecProfiles.append(CodecProfile(applyConditions: h264CodecConditions, codec: "h264", type: .video))
let hevcCodecConditions: [ProfileCondition] = [
ProfileCondition(
condition: .notEquals,
isRequired: false,
property: .isAnamorphic,
value: "true"
),
ProfileCondition(
condition: .equalsAny,
isRequired: false,
property: .videoProfile,
value: "high|main|main 10"
),
ProfileCondition(
condition: .lessThanEqual,
isRequired: false,
property: .videoLevel,
value: "175"
),
ProfileCondition(
condition: .notEquals,
isRequired: false,
property: .isInterlaced,
value: "true"
),
]
codecProfiles.append(CodecProfile(applyConditions: hevcCodecConditions, codec: "hevc", type: .video))
return codecProfiles
}
}

View File

@ -1,98 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
extension DeviceProfile {
static func swiftfinProfile() -> DeviceProfile {
var profile: DeviceProfile = .init()
// Build direct play profiles
profile.directPlayProfiles = [
// Just make one profile because if VLCKit can't decode it in a certain container, ffmpeg probably can't decode it for
// transcode either
DirectPlayProfile(
// No need to list containers or videocodecs since if jellyfin server can detect it/ffmpeg can decode it, so can
// VLCKit
// However, list audiocodecs because ffmpeg can decode TrueHD/mlp but VLCKit cannot
audioCodec: "flac,alac,aac,eac3,ac3,dts,opus,vorbis,mp3,mp2,mp1,pcm_s24be,pcm_s24le,pcm_s16be,pcm_s16le,pcm_u8,pcm_alaw,pcm_mulaw,pcm_bluray,pcm_dvd,wavpack,wmav2,wmav1,wmapro,wmalossless,nellymoser,speex,amr_nb,amr_wb",
type: .video
),
]
// Build transcoding profiles
// The only cases where transcoding should occur:
// 1) TrueHD/mlp audio
// 2) When server forces transcode for bitrate reasons
profile.transcodingProfiles = [TranscodingProfile(
audioCodec: "flac,alac,aac,eac3,ac3,dts,opus,vorbis,mp3,mp2,mp1",
// no PCM,wavpack,wmav2,wmav1,wmapro,wmalossless,nellymoser,speex,amr_nb,amr_wb in mp4
isBreakOnNonKeyFrames: true,
container: "mp4",
context: .streaming,
maxAudioChannels: "8",
minSegments: 2,
protocol: "hls",
type: .video,
videoCodec: "hevc,h264,av1,vp9,vc1,mpeg4,h263,mpeg2video,mpeg1video,mjpeg" // vp8,msmpeg4v3,msmpeg4v2,msmpeg4v1,theora,ffv1,flv1,wmv3,wmv2,wmv1
// not supported in mp4
)]
// Create subtitle profiles
profile.subtitleProfiles = [
SubtitleProfile(format: "pgssub", method: .embed), // *pgs* normalized to pgssub; includes sup
SubtitleProfile(format: "dvdsub", method: .embed),
// *dvd* normalized to dvdsub; includes sub/idx I think; microdvd case?
SubtitleProfile(format: "subrip", method: .embed), // srt
SubtitleProfile(format: "ass", method: .embed),
SubtitleProfile(format: "ssa", method: .embed),
SubtitleProfile(format: "vtt", method: .embed), // webvtt
SubtitleProfile(format: "mov_text", method: .embed), // MPEG-4 Timed Text
SubtitleProfile(format: "ttml", method: .embed),
SubtitleProfile(format: "text", method: .embed), // txt
SubtitleProfile(format: "dvbsub", method: .embed),
// dvb_subtitle normalized to dvbsub; burned in during transcode regardless?
SubtitleProfile(format: "libzvbi_teletextdec", method: .embed), // dvb_teletext
SubtitleProfile(format: "xsub", method: .embed),
SubtitleProfile(format: "vplayer", method: .embed),
SubtitleProfile(format: "subviewer", method: .embed),
SubtitleProfile(format: "subviewer1", method: .embed),
SubtitleProfile(format: "sami", method: .embed), // SMI
SubtitleProfile(format: "realtext", method: .embed),
SubtitleProfile(format: "pjs", method: .embed), // Phoenix Subtitle
SubtitleProfile(format: "mpl2", method: .embed),
SubtitleProfile(format: "jacosub", method: .embed),
SubtitleProfile(format: "cc_dec", method: .embed), // eia_608
// Can be passed as external files; ones that jellyfin can encode to must come first
SubtitleProfile(format: "subrip", method: .external), // srt
SubtitleProfile(format: "ttml", method: .external),
SubtitleProfile(format: "vtt", method: .external), // webvtt
SubtitleProfile(format: "ass", method: .external),
SubtitleProfile(format: "ssa", method: .external),
SubtitleProfile(format: "pgssub", method: .external),
SubtitleProfile(format: "text", method: .external), // txt
SubtitleProfile(format: "dvbsub", method: .external), // dvb_subtitle normalized to dvbsub
SubtitleProfile(format: "libzvbi_teletextdec", method: .external), // dvb_teletext
SubtitleProfile(format: "dvdsub", method: .external),
// *dvd* normalized to dvdsub; includes sub/idx I think; microdvd case?
SubtitleProfile(format: "xsub", method: .external),
SubtitleProfile(format: "vplayer", method: .external),
SubtitleProfile(format: "subviewer", method: .external),
SubtitleProfile(format: "subviewer1", method: .external),
SubtitleProfile(format: "sami", method: .external), // SMI
SubtitleProfile(format: "realtext", method: .external),
SubtitleProfile(format: "pjs", method: .external), // Phoenix Subtitle
SubtitleProfile(format: "mpl2", method: .external),
SubtitleProfile(format: "jacosub", method: .external),
]
return profile
}
}

View File

@ -1,38 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
extension DeviceProfile {
static func build(for videoPlayer: VideoPlayerType, maxBitrate: Int? = nil) -> DeviceProfile {
var deviceProfile: DeviceProfile
switch videoPlayer {
case .native:
deviceProfile = nativeProfile()
case .swiftfin:
deviceProfile = swiftfinProfile()
}
let codecProfiles: [CodecProfile] = sharedCodecProfiles()
let responseProfiles: [ResponseProfile] = [ResponseProfile(container: "m4v", mimeType: "video/mp4", type: .video)]
deviceProfile.codecProfiles = codecProfiles
deviceProfile.responseProfiles = responseProfiles
if let maxBitrate {
deviceProfile.maxStaticBitrate = maxBitrate
deviceProfile.maxStreamingBitrate = maxBitrate
deviceProfile.musicStreamingTranscodingBitrate = maxBitrate
}
return deviceProfile
}
}

View File

@ -0,0 +1,45 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
extension DirectPlayProfile {
init(
type: DlnaProfileType,
@CommaStringBuilder<AudioCodec> audioCodecs: () -> String = { "" },
@CommaStringBuilder<VideoCodec> videoCodecs: () -> String = { "" },
@CommaStringBuilder<MediaContainer> containers: () -> String = { "" }
) {
let audioCodecs = audioCodecs()
let videoCodecs = videoCodecs()
let containers = containers()
self.init(
audioCodec: audioCodecs.isEmpty ? nil : audioCodecs,
container: containers.isEmpty ? nil : containers,
type: type,
videoCodec: videoCodecs.isEmpty ? nil : videoCodecs
)
}
init(
type: DlnaProfileType,
audioCodecs: [AudioCodec],
videoCodecs: [VideoCodec],
containers: [MediaContainer]
) {
self.init(
type: type,
audioCodecs: { audioCodecs },
videoCodecs: { videoCodecs },
containers: { containers }
)
}
}

View File

@ -22,7 +22,7 @@ extension MediaSourceInfo {
let playbackURL: URL
let streamType: StreamType
if let transcodingURL, !Defaults[.Experimental.forceDirectPlay] {
if let transcodingURL {
guard let fullTranscodeURL = userSession.client.fullURL(with: transcodingURL)
else { throw JellyfinAPIError("Unable to make transcode URL") }
playbackURL = fullTranscodeURL
@ -61,7 +61,8 @@ extension MediaSourceInfo {
subtitleStreams: subtitleStreams,
selectedAudioStreamIndex: defaultAudioStreamIndex ?? -1,
selectedSubtitleStreamIndex: defaultSubtitleStreamIndex ?? -1,
chapters: item.fullChapterInfo,
// chapters: item.fullChapterInfo,
chapters: [],
streamType: streamType
)
}
@ -72,7 +73,7 @@ extension MediaSourceInfo {
let playbackURL: URL
let streamType: StreamType
if let transcodingURL, !Defaults[.Experimental.liveTVForceDirectPlay] {
if let transcodingURL {
guard let fullTranscodeURL = URL(string: transcodingURL, relativeTo: userSession.server.currentURL)
else { throw JellyfinAPIError("Unable to construct transcoded url") }
playbackURL = fullTranscodeURL

View File

@ -0,0 +1,40 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
extension SubtitleProfile {
init(
didlMode: String? = nil,
format: String? = nil,
language: String? = nil,
method: SubtitleDeliveryMethod? = nil,
@ArrayBuilder<SubtitleFormat> containers: () -> String = { "" }
) {
let containers = containers()
self.init(
container: containers.isEmpty ? nil : containers,
didlMode: didlMode,
format: format,
language: language,
method: method
)
}
static func build(
method: SubtitleDeliveryMethod,
@ArrayBuilder<SubtitleFormat> containers: () -> [SubtitleFormat]
) -> [SubtitleProfile] {
containers().map {
SubtitleProfile(container: $0.rawValue, method: method)
}
}
}

View File

@ -0,0 +1,55 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
extension TranscodingProfile {
init(
isBreakOnNonKeyFrames: Bool? = nil,
conditions: [ProfileCondition]? = nil,
context: EncodingContext? = nil,
isCopyTimestamps: Bool? = nil,
enableMpegtsM2TsMode: Bool? = nil,
enableSubtitlesInManifest: Bool? = nil,
isEstimateContentLength: Bool? = nil,
maxAudioChannels: String? = nil,
minSegments: Int? = nil,
protocol: String? = nil,
segmentLength: Int? = nil,
transcodeSeekInfo: TranscodeSeekInfo? = nil,
type: DlnaProfileType? = nil,
@CommaStringBuilder<AudioCodec> audioCodecs: () -> String = { "" },
@CommaStringBuilder<VideoCodec> videoCodecs: () -> String = { "" },
@CommaStringBuilder<MediaContainer> containers: () -> String = { "" }
) {
let audioCodecs = audioCodecs()
let videoCodecs = videoCodecs()
let containers = containers()
self.init(
audioCodec: audioCodecs.isEmpty ? nil : audioCodecs,
isBreakOnNonKeyFrames: isBreakOnNonKeyFrames,
conditions: conditions,
container: containers.isEmpty ? nil : containers,
context: context,
isCopyTimestamps: isCopyTimestamps,
enableMpegtsM2TsMode: enableMpegtsM2TsMode,
enableSubtitlesInManifest: enableSubtitlesInManifest,
isEstimateContentLength: isEstimateContentLength,
maxAudioChannels: maxAudioChannels,
minSegments: minSegments,
protocol: `protocol`,
segmentLength: segmentLength,
transcodeSeekInfo: transcodeSeekInfo,
type: type,
videoCodec: videoCodecs.isEmpty ? nil : videoCodecs
)
}
}

View File

@ -0,0 +1,41 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Foundation
@resultBuilder
public enum ArrayBuilder<Component> {
public static func buildBlock(_ components: [Component]...) -> [Component] {
components.flatMap { $0 }
}
public static func buildExpression(_ expression: Component) -> [Component] {
[expression]
}
public static func buildOptional(_ component: [Component]?) -> [Component] {
component ?? []
}
public static func buildEither(first component: [Component]) -> [Component] {
component
}
public static func buildEither(second component: [Component]) -> [Component] {
component
}
public static func buildArray(_ components: [[Component]]) -> [Component] {
components.flatMap { $0 }
}
public static func buildExpression(_ expression: [Component]) -> [Component] {
expression
}
}

View File

@ -0,0 +1,27 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Foundation
/// Result builder that build a comma-separated string from its components
@resultBuilder
struct CommaStringBuilder<Component> where Component: RawRepresentable<String> {
static func buildBlock(_ components: String...) -> String {
components.joined(separator: ",")
}
static func buildExpression(_ expression: Component) -> String {
expression.rawValue
}
static func buildExpression(_ expression: [Component]) -> String {
expression.map(\.rawValue)
.joined(separator: ",")
}
}

View File

@ -9,17 +9,17 @@
import Defaults
import Foundation
enum VideoPlayerType: String, CaseIterable, Defaults.Serializable, Displayable {
enum CustomDeviceProfileAction: String, CaseIterable, Displayable, Storable {
case native
case swiftfin
case add
case replace
var displayTitle: String {
switch self {
case .native:
return "Native"
case .swiftfin:
return "Swiftfin"
case .add:
return "Add"
case .replace:
return "Replace"
}
}
}

View File

@ -0,0 +1,114 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Defaults
enum AudioCodec: String, CaseIterable, Codable, Displayable, Defaults.Serializable {
case aac
case ac3
case amr_nb
case amr_wb
case dts
case dts_hd
case eac3
case flac
case alac
case mlp
case mp1
case mp2
case mp3
case nellymoser
case opus
case pcm_alaw
case pcm_bluray
case pcm_dvd
case pcm_mulaw
case pcm_s16be
case pcm_s16le
case pcm_s24be
case pcm_s24le
case pcm_u8
case speex
case truehd
case vorbis
case wavpack
case wmalossless
case wmapro
case wmav1
case wmav2
var displayTitle: String {
switch self {
case .aac:
return "AAC"
case .ac3:
return "AC-3"
case .amr_nb:
return "AMR-NB"
case .amr_wb:
return "AMR-WB"
case .dts:
return "DTS"
case .dts_hd:
return "DTS-HD"
case .eac3:
return "E-AC-3"
case .flac:
return "FLAC"
case .alac:
return "ALAC"
case .mlp:
return "MLP"
case .mp1:
return "MP1"
case .mp2:
return "MP2"
case .mp3:
return "MP3"
case .nellymoser:
return "Nellymoser"
case .opus:
return "Opus"
case .pcm_alaw:
return "PCM ALAW"
case .pcm_bluray:
return "PCM Bluray"
case .pcm_dvd:
return "PCM DVD"
case .pcm_mulaw:
return "PCM MULAW"
case .pcm_s16be:
return "PCM S16BE"
case .pcm_s16le:
return "PCM S16LE"
case .pcm_s24be:
return "PCM S24BE"
case .pcm_s24le:
return "PCM S24LE"
case .pcm_u8:
return "PCM U8"
case .speex:
return "Speex"
case .truehd:
return "TrueHD"
case .vorbis:
return "Vorbis"
case .wavpack:
return "WavPack"
case .wmalossless:
return "WMA Lossless"
case .wmapro:
return "WMA Pro"
case .wmav1:
return "WMA V1"
case .wmav2:
return "WMA V2"
}
}
}

View File

@ -0,0 +1,51 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Defaults
enum MediaContainer: String, CaseIterable, Codable, Displayable, Defaults.Serializable {
case avi
case flv
case m4v
case mkv
case mov
case mp4
case mpegts
case ts
case threeG2 = "3g2"
case threeGP = "3gp"
case webm
var displayTitle: String {
switch self {
case .avi:
return "AVI"
case .flv:
return "FLV"
case .m4v:
return "M4V"
case .mkv:
return "MKV"
case .mov:
return "MOV"
case .mp4:
return "MP4"
case .mpegts:
return "MPEG-TS"
case .ts:
return "TS"
case .threeG2:
return "3G2"
case .threeGP:
return "3GP"
case .webm:
return "WEBM"
}
}
}

View File

@ -0,0 +1,82 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Defaults
import JellyfinAPI
enum SubtitleFormat: String, CaseIterable, Codable, Displayable, Defaults.Serializable {
case ass
case cc_dec
case dvdsub
case dvbsub
case jacosub
case libzvbi_teletextdec
case mov_text
case mpl2
case pjs
case pgssub
case realtext
case sami
case ssa
case subrip
case subviewer
case subviewer1
case text
case ttml
case vplayer
case vtt
case xsub
var displayTitle: String {
switch self {
case .ass:
return "ASS"
case .cc_dec:
return "EIA-608"
case .dvdsub:
return "DVD Subtitle"
case .dvbsub:
return "DVB Subtitle"
case .jacosub:
return "Jacosub"
case .libzvbi_teletextdec:
return "DVB Teletext"
case .mov_text:
return "MPEG-4 Timed Text"
case .mpl2:
return "MPL2"
case .pjs:
return "Phoenix Subtitle"
case .pgssub:
return "PGS Subtitle"
case .realtext:
return "RealText"
case .sami:
return "SMI"
case .ssa:
return "SSA"
case .subrip:
return "SRT"
case .subviewer:
return "SubViewer"
case .subviewer1:
return "SubViewer1"
case .text:
return "TXT"
case .ttml:
return "TTML"
case .vplayer:
return "VPlayer"
case .vtt:
return "WebVTT"
case .xsub:
return "XSUB"
}
}
}

View File

@ -0,0 +1,90 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Defaults
enum VideoCodec: String, CaseIterable, Codable, Displayable, Defaults.Serializable {
case av1
case dv
case dirac
case ffv1
case flv1
case h261
case h263
case h264
case hevc
case mjpeg
case mpeg1video
case mpeg2video
case mpeg4
case msmpeg4v1
case msmpeg4v2
case msmpeg4v3
case prores
case theora
case vc1
case vp8
case vp9
case wmv1
case wmv2
case wmv3
var displayTitle: String {
switch self {
case .av1:
return "AV1"
case .dv:
return "DV"
case .dirac:
return "Dirac"
case .ffv1:
return "FFV1"
case .flv1:
return "FLV1"
case .h261:
return "H.261"
case .h263:
return "H.263"
case .h264:
return "H.264"
case .hevc:
return "HEVC"
case .mjpeg:
return "MJPEG"
case .mpeg1video:
return "MPEG-1 Video"
case .mpeg2video:
return "MPEG-2 Video"
case .mpeg4:
return "MPEG-4"
case .msmpeg4v1:
return "MS MPEG-4 v1"
case .msmpeg4v2:
return "MS MPEG-4 v2"
case .msmpeg4v3:
return "MS MPEG-4 v3"
case .prores:
return "ProRes"
case .theora:
return "Theora"
case .vc1:
return "VC-1"
case .vp8:
return "VP8"
case .vp9:
return "VP9"
case .wmv1:
return "WMV1"
case .wmv2:
return "WMV2"
case .wmv3:
return "WMV3"
}
}
}

View File

@ -0,0 +1,54 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
extension PlaybackCompatibility {
enum Video {
// MARK: - Compatibility Profiles
@ArrayBuilder<DirectPlayProfile>
static var compatibilityDirectPlayProfile: [DirectPlayProfile] {
DirectPlayProfile(type: .video) {
AudioCodec.aac
} videoCodecs: {
VideoCodec.h264
} containers: {
MediaContainer.mp4
}
}
@ArrayBuilder<TranscodingProfile>
static var compatibilityTranscodingProfile: [TranscodingProfile] {
TranscodingProfile(
isBreakOnNonKeyFrames: true,
context: .streaming,
maxAudioChannels: "8",
minSegments: 2,
protocol: StreamType.hls.rawValue,
type: .video
) {
AudioCodec.aac
} videoCodecs: {
VideoCodec.h264
} containers: {
MediaContainer.mp4
}
}
// MARK: - Direct Profile
@ArrayBuilder<DirectPlayProfile>
static var forcedDirectPlayProfile: [DirectPlayProfile] {
DirectPlayProfile(type: .video)
}
}
}

View File

@ -0,0 +1,31 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Defaults
import JellyfinAPI
enum PlaybackCompatibility: String, CaseIterable, Defaults.Serializable, Displayable {
case auto
case mostCompatible
case directPlay
case custom
var displayTitle: String {
switch self {
case .auto:
return L10n.auto
case .mostCompatible:
return L10n.compatible
case .directPlay:
return L10n.direct
case .custom:
return L10n.custom
}
}
}

View File

@ -0,0 +1,73 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Defaults
import Foundation
import JellyfinAPI
struct CustomDeviceProfile: Hashable, Storable {
let type: DlnaProfileType
var useAsTranscodingProfile: Bool
var audio: [AudioCodec]
var video: [VideoCodec]
var container: [MediaContainer]
init(
type: DlnaProfileType,
useAsTranscodingProfile: Bool = false,
audio: [AudioCodec] = [],
video: [VideoCodec] = [],
container: [MediaContainer] = []
) {
self.type = type
self.useAsTranscodingProfile = useAsTranscodingProfile
self.audio = audio
self.video = video
self.container = container
}
var directPlayProfile: DirectPlayProfile {
switch type {
case .video:
return DirectPlayProfile(
type: type,
audioCodecs: audio,
videoCodecs: video,
containers: container
)
default:
assertionFailure("Only Video is currently supported.")
return DirectPlayProfile()
}
}
var transcodingProfile: TranscodingProfile {
switch type {
case .video:
return TranscodingProfile(
isBreakOnNonKeyFrames: true,
context: .streaming,
maxAudioChannels: "8",
minSegments: 2,
protocol: StreamType.hls.rawValue,
type: .video
) {
audio
} videoCodecs: {
video
} containers: {
container
}
default:
assertionFailure("Only Video is currently supported.")
return TranscodingProfile(audioCodec: nil)
}
}
}

View File

@ -8,7 +8,7 @@
import Foundation
enum StreamType: Displayable {
enum StreamType: String, Displayable {
case direct
case transcode

View File

@ -0,0 +1,143 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
extension VideoPlayerType {
// MARK: direct play
@ArrayBuilder<DirectPlayProfile>
static var _nativeDirectPlayProfiles: [DirectPlayProfile] {
DirectPlayProfile(type: .video) {
AudioCodec.aac
AudioCodec.ac3
AudioCodec.alac
AudioCodec.eac3
AudioCodec.flac
AudioCodec.opus
} videoCodecs: {
VideoCodec.h261
VideoCodec.hevc
VideoCodec.mpeg4
} containers: {
MediaContainer.mp4
}
DirectPlayProfile(type: .video) {
AudioCodec.aac
AudioCodec.ac3
AudioCodec.alac
} videoCodecs: {
VideoCodec.h264
VideoCodec.mpeg4
} containers: {
MediaContainer.m4v
}
DirectPlayProfile(type: .video) {
AudioCodec.aac
AudioCodec.ac3
AudioCodec.alac
AudioCodec.eac3
AudioCodec.mp3
AudioCodec.pcm_s16be
AudioCodec.pcm_s16le
AudioCodec.pcm_s24be
AudioCodec.pcm_s24le
} videoCodecs: {
VideoCodec.h264
VideoCodec.hevc
VideoCodec.mjpeg
VideoCodec.mpeg4
} containers: {
MediaContainer.mov
}
DirectPlayProfile(type: .video) {
AudioCodec.aac
AudioCodec.ac3
AudioCodec.eac3
AudioCodec.mp3
} videoCodecs: {
VideoCodec.h264
} containers: {
MediaContainer.mpegts
}
DirectPlayProfile(type: .video) {
AudioCodec.aac
AudioCodec.amr_nb
} videoCodecs: {
VideoCodec.h264
VideoCodec.mpeg4
} containers: {
MediaContainer.threeG2
MediaContainer.threeGP
}
DirectPlayProfile(type: .video) {
AudioCodec.pcm_mulaw
AudioCodec.pcm_s16le
} videoCodecs: {
VideoCodec.mjpeg
} containers: {
MediaContainer.avi
}
}
// MARK: transcoding
@ArrayBuilder<TranscodingProfile>
static var _nativeTranscodingProfiles: [TranscodingProfile] {
TranscodingProfile(
isBreakOnNonKeyFrames: true,
context: .streaming,
enableSubtitlesInManifest: true,
maxAudioChannels: "8",
minSegments: 2,
protocol: "hls",
type: .video
) {
AudioCodec.aac
AudioCodec.ac3
AudioCodec.alac
AudioCodec.eac3
AudioCodec.flac
AudioCodec.opus
} videoCodecs: {
VideoCodec.h264
VideoCodec.hevc
VideoCodec.mpeg4
} containers: {
MediaContainer.mp4
}
}
// MARK: subtitle
@ArrayBuilder<SubtitleProfile>
static var _nativeSubtitleProfiles: [SubtitleProfile] {
SubtitleProfile.build(method: .embed) {
SubtitleFormat.cc_dec
SubtitleFormat.ttml
}
SubtitleProfile.build(method: .encode) {
SubtitleFormat.dvbsub
SubtitleFormat.dvdsub
SubtitleFormat.pgssub
SubtitleFormat.xsub
}
SubtitleProfile.build(method: .hls) {
SubtitleFormat.vtt
}
}
}

View File

@ -0,0 +1,97 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
extension VideoPlayerType {
// MARK: codec profiles
@ArrayBuilder<CodecProfile>
var codecProfiles: [CodecProfile] {
CodecProfile(
codec: VideoCodec.h264.rawValue,
type: .video,
applyConditions: {
ProfileCondition(
condition: .notEquals,
isRequired: false,
property: .isAnamorphic,
value: "true"
)
ProfileCondition(
condition: .equalsAny,
isRequired: false,
property: .videoProfile,
value: "high|main|baseline|constrained baseline"
)
ProfileCondition(
condition: .lessThanEqual,
isRequired: false,
property: .videoLevel,
value: "80"
)
ProfileCondition(
condition: .notEquals,
isRequired: false,
property: .isInterlaced,
value: "true"
)
}
)
CodecProfile(
codec: VideoCodec.hevc.rawValue,
type: .video,
applyConditions: {
ProfileCondition(
condition: .notEquals,
isRequired: false,
property: .isAnamorphic,
value: "true"
)
ProfileCondition(
condition: .equalsAny,
isRequired: false,
property: .videoProfile,
value: "high|main|main 10"
)
ProfileCondition(
condition: .lessThanEqual,
isRequired: false,
property: .videoLevel,
value: "175"
)
ProfileCondition(
condition: .notEquals,
isRequired: false,
property: .isInterlaced,
value: "true"
)
}
)
}
// MARK: - response profiles
@ArrayBuilder<ResponseProfile>
var responseProfiles: [ResponseProfile] {
ResponseProfile(
container: MediaContainer.m4v.rawValue,
mimeType: "video/mp4",
type: .video
)
}
}

View File

@ -0,0 +1,140 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Foundation
import JellyfinAPI
extension VideoPlayerType {
// MARK: direct play
@ArrayBuilder<DirectPlayProfile>
static var _swiftfinDirectPlayProfiles: [DirectPlayProfile] {
DirectPlayProfile(type: .video) {
AudioCodec.aac
AudioCodec.ac3
AudioCodec.alac
AudioCodec.amr_nb
AudioCodec.amr_wb
AudioCodec.dts
AudioCodec.eac3
AudioCodec.flac
AudioCodec.mp1
AudioCodec.mp2
AudioCodec.mp3
AudioCodec.nellymoser
AudioCodec.opus
AudioCodec.pcm_alaw
AudioCodec.pcm_bluray
AudioCodec.pcm_dvd
AudioCodec.pcm_mulaw
AudioCodec.pcm_s16be
AudioCodec.pcm_s16le
AudioCodec.pcm_s24be
AudioCodec.pcm_s24le
AudioCodec.pcm_u8
AudioCodec.speex
AudioCodec.vorbis
AudioCodec.wavpack
AudioCodec.wmalossless
AudioCodec.wmapro
AudioCodec.wmav1
AudioCodec.wmav2
}
}
// MARK: transcoding
@ArrayBuilder<TranscodingProfile>
static var _swiftfinTranscodingProfiles: [TranscodingProfile] {
TranscodingProfile(
isBreakOnNonKeyFrames: true,
context: .streaming,
maxAudioChannels: "8",
minSegments: 2,
protocol: StreamType.hls.rawValue,
type: .video
) {
AudioCodec.aac
AudioCodec.ac3
AudioCodec.alac
AudioCodec.dts
AudioCodec.eac3
AudioCodec.flac
AudioCodec.mp1
AudioCodec.mp2
AudioCodec.mp3
AudioCodec.opus
AudioCodec.vorbis
} videoCodecs: {
VideoCodec.av1
VideoCodec.h263
VideoCodec.h264
VideoCodec.hevc
VideoCodec.mjpeg
VideoCodec.mpeg1video
VideoCodec.mpeg2video
VideoCodec.mpeg4
VideoCodec.vc1
VideoCodec.vp9
} containers: {
MediaContainer.mp4
}
}
// MARK: subtitle
@ArrayBuilder<SubtitleProfile>
static var _swiftfinSubtitleProfiles: [SubtitleProfile] {
SubtitleProfile.build(method: .embed) {
SubtitleFormat.ass
SubtitleFormat.cc_dec
SubtitleFormat.dvbsub
SubtitleFormat.dvdsub
SubtitleFormat.jacosub
SubtitleFormat.libzvbi_teletextdec
SubtitleFormat.mov_text
SubtitleFormat.mpl2
SubtitleFormat.pgssub
SubtitleFormat.pjs
SubtitleFormat.realtext
SubtitleFormat.sami
SubtitleFormat.ssa
SubtitleFormat.subrip
SubtitleFormat.subviewer
SubtitleFormat.subviewer1
SubtitleFormat.text
SubtitleFormat.ttml
SubtitleFormat.vplayer
SubtitleFormat.vtt
SubtitleFormat.xsub
}
SubtitleProfile.build(method: .external) {
SubtitleFormat.ass
SubtitleFormat.dvbsub
SubtitleFormat.dvdsub
SubtitleFormat.jacosub
SubtitleFormat.libzvbi_teletextdec
SubtitleFormat.mpl2
SubtitleFormat.pgssub
SubtitleFormat.pjs
SubtitleFormat.realtext
SubtitleFormat.sami
SubtitleFormat.ssa
SubtitleFormat.subrip
SubtitleFormat.subviewer
SubtitleFormat.subviewer1
SubtitleFormat.text
SubtitleFormat.ttml
SubtitleFormat.vplayer
SubtitleFormat.vtt
SubtitleFormat.xsub
}
}
}

View File

@ -0,0 +1,54 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Defaults
import Foundation
import JellyfinAPI
import SwiftUI
enum VideoPlayerType: String, CaseIterable, Defaults.Serializable, Displayable {
case native
case swiftfin
var displayTitle: String {
switch self {
case .native:
"Native"
case .swiftfin:
"Swiftfin"
}
}
var directPlayProfiles: [DirectPlayProfile] {
switch self {
case .native:
Self._nativeDirectPlayProfiles
case .swiftfin:
Self._swiftfinDirectPlayProfiles
}
}
var transcodingProfiles: [TranscodingProfile] {
switch self {
case .native:
Self._nativeTranscodingProfiles
case .swiftfin:
Self._swiftfinTranscodingProfiles
}
}
var subtitleProfiles: [SubtitleProfile] {
switch self {
case .native:
Self._nativeSubtitleProfiles
case .swiftfin:
Self._swiftfinSubtitleProfiles
}
}
}

View File

@ -218,6 +218,13 @@ extension Defaults.Keys {
static let timestampType: Key<TimestampType> = UserKey("timestampType", default: .split)
}
enum Playback {
static let appMaximumBitrate: Key<PlaybackBitrate> = UserKey("appMaximumBitrate", default: .auto)
static let appMaximumBitrateTest: Key<PlaybackBitrateTestSize> = UserKey("appMaximumBitrateTest", default: .regular)
static let compatibilityMode: Key<PlaybackCompatibility> = UserKey("compatibilityMode", default: .auto)
static let customDeviceProfileAction: Key<CustomDeviceProfileAction> = UserKey("customDeviceProfileAction", default: .add)
}
enum Subtitle {
static let subtitleColor: Key<Color> = UserKey("subtitleColor", default: .white)
@ -235,8 +242,6 @@ extension Defaults.Keys {
enum Experimental {
static let downloads: Key<Bool> = UserKey("experimentalDownloads", default: false)
static let forceDirectPlay: Key<Bool> = UserKey("forceDirectPlay", default: false)
static let liveTVForceDirectPlay: Key<Bool> = UserKey("liveTVForceDirectPlay", default: false)
}
// tvos specific

View File

@ -52,14 +52,22 @@ internal enum L10n {
internal static let audioTrack = L10n.tr("Localizable", "audioTrack", fallback: "Audio Track")
/// Authorize
internal static let authorize = L10n.tr("Localizable", "authorize", fallback: "Authorize")
/// Auto
internal static let auto = L10n.tr("Localizable", "auto", fallback: "Auto")
/// Auto Play
internal static let autoPlay = L10n.tr("Localizable", "autoPlay", fallback: "Auto Play")
/// Back
internal static let back = L10n.tr("Localizable", "back", fallback: "Back")
/// Bar Buttons
internal static let barButtons = L10n.tr("Localizable", "barButtons", fallback: "Bar Buttons")
/// Behavior
internal static let behavior = L10n.tr("Localizable", "behavior", fallback: "Behavior")
/// Auto
internal static let bitrateAuto = L10n.tr("Localizable", "bitrateAuto", fallback: "Auto")
/// Default Bitrate
internal static let bitrateDefault = L10n.tr("Localizable", "bitrateDefault", fallback: "Default Bitrate")
/// Limits the internet bandwidth used during video playback
internal static let bitrateDefaultDescription = L10n.tr("Localizable", "bitrateDefaultDescription", fallback: "Limits the internet bandwidth used during video playback")
/// 480p - 1.5 Mbps
internal static let bitrateKbps1500 = L10n.tr("Localizable", "bitrateKbps1500", fallback: "480p - 1.5 Mbps")
/// 360p - 420 Kbps
@ -90,8 +98,12 @@ internal enum L10n {
internal static let bitrateMbps8 = L10n.tr("Localizable", "bitrateMbps8", fallback: "720p - 8 Mbps")
/// 4K - 80 Mbps
internal static let bitrateMbps80 = L10n.tr("Localizable", "bitrateMbps80", fallback: "4K - 80 Mbps")
/// Larger tests result in a more accurate bitrate but may delay playback
internal static let bitrateTestDescription = L10n.tr("Localizable", "bitrateTestDescription", fallback: "Larger tests result in a more accurate bitrate but may delay playback")
/// Bitrate Test
internal static let bitrateTest = L10n.tr("Localizable", "bitrateTest", fallback: "Bitrate Test")
/// Determines the length of the 'Auto' bitrate test used to find the available internet bandwidth
internal static let bitrateTestDescription = L10n.tr("Localizable", "bitrateTestDescription", fallback: "Determines the length of the 'Auto' bitrate test used to find the available internet bandwidth")
/// Longer tests are more accurate but may result in a delayed playback
internal static let bitrateTestDisclaimer = L10n.tr("Localizable", "bitrateTestDisclaimer", fallback: "Longer tests are more accurate but may result in a delayed playback")
/// Blue
internal static let blue = L10n.tr("Localizable", "blue", fallback: "Blue")
/// Bugs and Features
@ -136,6 +148,10 @@ internal enum L10n {
internal static let compactLogo = L10n.tr("Localizable", "compactLogo", fallback: "Compact Logo")
/// Compact Poster
internal static let compactPoster = L10n.tr("Localizable", "compactPoster", fallback: "Compact Poster")
/// Compatibility
internal static let compatibility = L10n.tr("Localizable", "compatibility", fallback: "Compatibility")
/// Most Compatible
internal static let compatible = L10n.tr("Localizable", "compatible", fallback: "Most Compatible")
/// Confirm Close
internal static let confirmClose = L10n.tr("Localizable", "confirmClose", fallback: "Confirm Close")
/// Connect
@ -160,8 +176,18 @@ internal enum L10n {
internal static let current = L10n.tr("Localizable", "current", fallback: "Current")
/// Current Position
internal static let currentPosition = L10n.tr("Localizable", "currentPosition", fallback: "Current Position")
/// Custom
internal static let custom = L10n.tr("Localizable", "custom", fallback: "Custom")
/// The custom device profiles will be added to the default Swiftfin device profiles
internal static let customDeviceProfileAdd = L10n.tr("Localizable", "customDeviceProfileAdd", fallback: "The custom device profiles will be added to the default Swiftfin device profiles")
/// Dictates back to the Jellyfin Server what this device hardware is capable of playing
internal static let customDeviceProfileDescription = L10n.tr("Localizable", "customDeviceProfileDescription", fallback: "Dictates back to the Jellyfin Server what this device hardware is capable of playing")
/// The custom device profiles will replace the default Swiftfin device profiles
internal static let customDeviceProfileReplace = L10n.tr("Localizable", "customDeviceProfileReplace", fallback: "The custom device profiles will replace the default Swiftfin device profiles")
/// Customize
internal static let customize = L10n.tr("Localizable", "customize", fallback: "Customize")
/// Custom Profile
internal static let customProfile = L10n.tr("Localizable", "customProfile", fallback: "Custom Profile")
/// Dark
internal static let dark = L10n.tr("Localizable", "dark", fallback: "Dark")
/// Default Scheme
@ -172,6 +198,10 @@ internal enum L10n {
internal static let deleteServer = L10n.tr("Localizable", "deleteServer", fallback: "Delete Server")
/// Delivery
internal static let delivery = L10n.tr("Localizable", "delivery", fallback: "Delivery")
/// Device Profile
internal static let deviceProfile = L10n.tr("Localizable", "deviceProfile", fallback: "Device Profile")
/// Direct Play
internal static let direct = L10n.tr("Localizable", "direct", fallback: "Direct Play")
/// DIRECTOR
internal static let director = L10n.tr("Localizable", "director", fallback: "DIRECTOR")
/// Disabled
@ -294,6 +324,8 @@ internal enum L10n {
internal static let logs = L10n.tr("Localizable", "logs", fallback: "Logs")
/// Maximum Bitrate
internal static let maximumBitrate = L10n.tr("Localizable", "maximumBitrate", fallback: "Maximum Bitrate")
/// This setting may result in media failing to start playback
internal static let mayResultInPlaybackFailure = L10n.tr("Localizable", "mayResultInPlaybackFailure", fallback: "This setting may result in media failing to start playback")
/// Media
internal static let media = L10n.tr("Localizable", "media", fallback: "Media")
/// Menu Buttons
@ -426,6 +458,8 @@ internal enum L10n {
internal static let previousItem = L10n.tr("Localizable", "previousItem", fallback: "Previous Item")
/// Primary
internal static let primary = L10n.tr("Localizable", "primary", fallback: "Primary")
/// Profiles
internal static let profiles = L10n.tr("Localizable", "profiles", fallback: "Profiles")
/// Programs
internal static let programs = L10n.tr("Localizable", "programs", fallback: "Programs")
/// Progress
@ -676,6 +710,8 @@ internal enum L10n {
internal static let unplayed = L10n.tr("Localizable", "unplayed", fallback: "Unplayed")
/// URL
internal static let url = L10n.tr("Localizable", "url", fallback: "URL")
/// Use as Transcoding Profile
internal static let useAsTranscodingProfile = L10n.tr("Localizable", "useAsTranscodingProfile", fallback: "Use as Transcoding Profile")
/// Use Primary Image
internal static let usePrimaryImage = L10n.tr("Localizable", "usePrimaryImage", fallback: "Use Primary Image")
/// Uses the primary image and hides the logo.

View File

@ -132,5 +132,13 @@ extension StoredValues.Keys {
default: ""
)
}
static var customDeviceProfiles: Key<[CustomDeviceProfile]> {
CurrentUserKey(
"customDeviceProfiles",
domain: "customDeviceProfiles",
default: []
)
}
}
}

View File

@ -0,0 +1,199 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Factory
import SwiftUI
struct OrderedSectionSelectorView<Element: Displayable & Hashable>: View {
@Environment(\.editMode)
private var editMode
@State
private var focusedElement: Element?
@StateObject
private var selection: BindingBox<[Element]>
private var disabledSelection: [Element] {
sources.filter { !selection.value.contains($0) }
}
private var label: (Element) -> any View
private let sources: [Element]
private var systemImage: String
private func move(from source: IndexSet, to destination: Int) {
selection.value.move(fromOffsets: source, toOffset: destination)
editMode?.wrappedValue = .inactive
}
private func select(element: Element) {
if selection.value.contains(element) {
selection.value.removeAll(where: { $0 == element })
} else {
selection.value.append(element)
}
}
var body: some View {
NavigationView {
SplitFormWindowView()
.descriptionView {
Image(systemName: systemImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 400)
}
.contentView {
List {
EnabledSection(
elements: $selection.value,
label: label,
isEditing: editMode?.wrappedValue.isEditing ?? false,
select: select,
move: move,
header: {
Group {
HStack {
Text(L10n.enabled)
Spacer()
if editMode?.wrappedValue.isEditing ?? false {
Button("Done") {
withAnimation {
editMode?.wrappedValue = .inactive
}
}
} else {
Button("Edit") {
withAnimation {
editMode?.wrappedValue = .active
}
}
}
}
}
}
)
DisabledSection(
elements: disabledSelection,
label: label,
isEditing: editMode?.wrappedValue.isEditing ?? false,
select: select
)
}
.environment(\.editMode, editMode)
}
.withDescriptionTopPadding()
.animation(.linear(duration: 0.2), value: selection.value)
}
}
}
private struct EnabledSection<Element: Displayable & Hashable>: View {
@Binding
var elements: [Element]
let label: (Element) -> any View
let isEditing: Bool
let select: (Element) -> Void
let move: (IndexSet, Int) -> Void
let header: () -> any View
var body: some View {
Section {
if elements.isEmpty {
Text(L10n.none)
.foregroundStyle(.secondary)
}
ForEach(elements, id: \.self) { element in
Button {
if !isEditing {
select(element)
}
} label: {
HStack {
label(element)
.eraseToAnyView()
Spacer()
if !isEditing {
Image(systemName: "minus.circle.fill")
.foregroundColor(.red)
}
}
.foregroundColor(.primary)
}
}
.onMove(perform: move)
} header: {
header()
.eraseToAnyView()
}
}
}
private struct DisabledSection<Element: Displayable & Hashable>: View {
let elements: [Element]
let label: (Element) -> any View
let isEditing: Bool
let select: (Element) -> Void
var body: some View {
Section(L10n.disabled) {
if elements.isEmpty {
Text(L10n.none)
.foregroundStyle(.secondary)
}
ForEach(elements, id: \.self) { element in
Button {
if !isEditing {
select(element)
}
} label: {
HStack {
label(element)
.eraseToAnyView()
Spacer()
if !isEditing {
Image(systemName: "plus.circle.fill")
.foregroundColor(.green)
}
}
.foregroundColor(.primary)
}
}
}
}
}
extension OrderedSectionSelectorView {
init(selection: Binding<[Element]>, sources: [Element]) {
self._selection = StateObject(wrappedValue: BindingBox(source: selection))
self.sources = sources
self.label = { Text($0.displayTitle).foregroundColor(.primary).eraseToAnyView() }
self.systemImage = "filemenu.and.selection"
}
func label(@ViewBuilder _ content: @escaping (Element) -> any View) -> Self {
copy(modifying: \.label, with: content)
}
func systemImage(_ systemName: String) -> Self {
copy(modifying: \.systemImage, with: systemName)
}
}

View File

@ -0,0 +1,65 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Foundation
import SwiftUI
extension CustomDeviceProfileSettingsView {
struct CustomProfileButton: View {
let profile: CustomDeviceProfile
var onSelect: () -> Void
@ViewBuilder
private func profileDetailsView(title: String, detail: String) -> some View {
VStack(alignment: .leading) {
Text(title)
.fontWeight(.semibold)
.foregroundStyle(.primary)
Text(detail)
.foregroundColor(.secondary)
}
.font(.subheadline)
}
var body: some View {
Button(action: onSelect) {
HStack {
VStack(alignment: .leading, spacing: 8) {
profileDetailsView(
title: L10n.audio,
detail: profile.audio.map(\.displayTitle).joined(separator: ", ")
)
profileDetailsView(
title: L10n.video,
detail: profile.video.map(\.displayTitle).joined(separator: ", ")
)
profileDetailsView(
title: L10n.containers,
detail: profile.container.map(\.displayTitle).joined(separator: ", ")
)
profileDetailsView(
title: L10n.useAsTranscodingProfile,
detail: profile.useAsTranscodingProfile ? "Yes" : "No"
)
}
Spacer()
Image(systemName: "chevron.right")
.font(.body.weight(.regular))
.foregroundColor(.secondary)
}
.padding()
}
}
}
}

View File

@ -0,0 +1,145 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import SwiftUI
extension CustomDeviceProfileSettingsView {
struct EditCustomDeviceProfileView: View {
@StoredValue(.User.customDeviceProfiles)
private var customDeviceProfiles
@EnvironmentObject
private var router: EditCustomDeviceProfileCoordinator.Router
@State
private var isPresentingNotSaved = false
@StateObject
private var profile: BindingBox<CustomDeviceProfile>
private let createProfile: Bool
private var isValid: Bool {
profile.value.audio.isNotEmpty &&
profile.value.video.isNotEmpty &&
profile.value.container.isNotEmpty
}
init(profile: Binding<CustomDeviceProfile>?) {
createProfile = profile == nil
if let profile {
self._profile = StateObject(wrappedValue: BindingBox(source: profile))
} else {
let empty = Binding<CustomDeviceProfile>(
get: { .init(type: .video) },
set: { _ in }
)
self._profile = StateObject(
wrappedValue: BindingBox(source: empty)
)
}
}
@ViewBuilder
private func codecSection(
title: String,
content: String,
onSelect: @escaping () -> Void
) -> some View {
Button(action: onSelect) {
HStack {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.fontWeight(.semibold)
if content.isEmpty {
Label(L10n.none, systemImage: "exclamationmark.circle.fill")
} else {
Text(content)
.foregroundColor(.secondary)
}
}
.font(.subheadline)
Spacer()
Image(systemName: "chevron.right")
.font(.body.weight(.regular))
.foregroundColor(.secondary)
}
}
}
var body: some View {
SplitFormWindowView()
.descriptionView {
Image(systemName: "doc")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 400)
}
.contentView {
Section {
Toggle(L10n.useAsTranscodingProfile, isOn: $profile.value.useAsTranscodingProfile)
.padding(.vertical)
} header: {
HStack {
Text(L10n.customProfile)
Spacer()
Button("Save") {
if createProfile {
customDeviceProfiles.append(profile.value)
}
router.dismissCoordinator()
}
.disabled(!isValid)
}
}
codecSection(
title: L10n.audio,
content: profile.value.audio.map(\.displayTitle).joined(separator: ", ")
) {
router.route(to: \.customDeviceAudioEditor, $profile.value.audio)
}
.padding(.vertical)
codecSection(
title: L10n.video,
content: profile.value.video.map(\.displayTitle).joined(separator: ", ")
) {
router.route(to: \.customDeviceVideoEditor, $profile.value.video)
}
.padding(.vertical)
codecSection(
title: L10n.containers,
content: profile.value.container.map(\.displayTitle).joined(separator: ", ")
) {
router.route(to: \.customDeviceContainerEditor, $profile.value.container)
}
.padding(.vertical)
if !isValid {
Label("Current profile values may cause playback issues", systemImage: "exclamationmark.circle.fill")
}
}
.navigationTitle(L10n.customProfile)
.alert("Profile not saved", isPresented: $isPresentingNotSaved) {
Button("Close", role: .destructive) {
router.dismissCoordinator()
}
}
.interactiveDismissDisabled(true)
}
}
}

View File

@ -0,0 +1,105 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Defaults
import Factory
import SwiftUI
struct CustomDeviceProfileSettingsView: View {
@Default(.VideoPlayer.Playback.customDeviceProfileAction)
private var customDeviceProfileAction
@StoredValue(.User.customDeviceProfiles)
private var customProfiles: [CustomDeviceProfile]
@EnvironmentObject
private var router: CustomDeviceProfileCoordinator.Router
private var isValid: Bool {
customDeviceProfileAction == .add || customProfiles.isNotEmpty
}
private func removeProfile(at offsets: IndexSet) {
customProfiles.remove(atOffsets: offsets)
}
private func deleteProfile(_ profile: CustomDeviceProfile) {
if let index = customProfiles.firstIndex(of: profile) {
customProfiles.remove(at: index)
}
}
var body: some View {
SplitFormWindowView()
.descriptionView {
Image(systemName: "doc.on.doc")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 400)
}
.contentView {
Section {
InlineEnumToggle(
title: L10n.behavior,
selection: $customDeviceProfileAction
)
} header: {
L10n.behavior.text
} footer: {
VStack(spacing: 8) {
switch customDeviceProfileAction {
case .add:
L10n.customDeviceProfileAdd.text
case .replace:
L10n.customDeviceProfileReplace.text
}
if !isValid {
Label("No profiles defined. Playback issues may occur.", systemImage: "exclamationmark.circle.fill")
}
}
}
Section {
if customProfiles.isEmpty {
Button("Add profile") {
router.route(to: \.createCustomDeviceProfile)
}
}
List {
ForEach($customProfiles, id: \.self) { $profile in
CustomProfileButton(profile: profile) {
router.route(to: \.editCustomDeviceProfile, $profile)
}
.contextMenu {
Button(role: .destructive) {
deleteProfile(profile)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
.onDelete(perform: removeProfile)
}
} header: {
HStack {
Text(L10n.profiles)
Spacer()
if customProfiles.isNotEmpty {
Button("Add") {
router.route(to: \.createCustomDeviceProfile)
}
}
}
}
}
.navigationTitle(L10n.profiles)
}
}

View File

@ -9,12 +9,10 @@
import Defaults
import SwiftUI
struct ExperimentalSettingsView: View {
// Note: Used for experimental settings that may be removed or implemented
// officially. Keep for future settings.
@Default(.Experimental.forceDirectPlay)
private var forceDirectPlay
@Default(.Experimental.liveTVForceDirectPlay)
private var liveTVForceDirectPlay
struct ExperimentalSettingsView: View {
var body: some View {
SplitFormWindowView()
@ -24,18 +22,7 @@ struct ExperimentalSettingsView: View {
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 400)
}
.contentView {
Section("Video Player") {
Toggle("Force Direct Play", isOn: $forceDirectPlay)
}
Section("Live TV") {
Toggle("Live TV Force Direct Play", isOn: $liveTVForceDirectPlay)
}
}
.contentView {}
.navigationTitle(L10n.experimental)
}
}

View File

@ -1,45 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Defaults
import SwiftUI
struct MaximumBitrateSettingsView: View {
@Default(.VideoPlayer.appMaximumBitrate)
private var appMaximumBitrate
@Default(.VideoPlayer.appMaximumBitrateTest)
private var appMaximumBitrateTest
var body: some View {
SplitFormWindowView()
.descriptionView {
Image(systemName: "network")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 400)
}
.contentView {
Section {
InlineEnumToggle(title: L10n.maximumBitrate, selection: $appMaximumBitrate)
if appMaximumBitrate == PlaybackBitrate.auto {
InlineEnumToggle(title: L10n.testSize, selection: $appMaximumBitrateTest)
}
} header: {
L10n.playbackQuality.text
} footer: {
if appMaximumBitrate == PlaybackBitrate.auto {
L10n.bitrateTestDescription.text
}
}
}
.navigationTitle(L10n.maximumBitrate)
}
}

View File

@ -0,0 +1,81 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Defaults
import SwiftUI
struct PlaybackQualitySettingsView: View {
@Default(.VideoPlayer.Playback.appMaximumBitrate)
private var appMaximumBitrate
@Default(.VideoPlayer.Playback.appMaximumBitrateTest)
private var appMaximumBitrateTest
@Default(.VideoPlayer.Playback.compatibilityMode)
private var compatibilityMode
@EnvironmentObject
private var router: PlaybackQualitySettingsCoordinator.Router
var body: some View {
SplitFormWindowView()
.descriptionView {
Image(systemName: "play.rectangle.on.rectangle")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 400)
}
.contentView {
Section {
InlineEnumToggle(
title: L10n.maximumBitrate,
selection: $appMaximumBitrate
)
} header: {
L10n.bitrateDefault.text
} footer: {
VStack(alignment: .leading) {
L10n.bitrateDefaultDescription.text
}
}
.animation(.none, value: appMaximumBitrate)
if appMaximumBitrate == .auto {
Section {
InlineEnumToggle(
title: L10n.testSize,
selection: $appMaximumBitrateTest
)
} header: {
L10n.bitrateTest.text
} footer: {
VStack(alignment: .leading, spacing: 8) {
L10n.bitrateTestDescription.text
L10n.bitrateTestDisclaimer.text
}
}
}
Section {
InlineEnumToggle(
title: L10n.compatibility,
selection: $compatibilityMode
)
.animation(.none, value: compatibilityMode)
if compatibilityMode == .custom {
ChevronButton(L10n.profiles)
.onSelect {
router.route(to: \.customDeviceProfileSettings)
}
}
} header: {
L10n.deviceProfile.text
}
}
.navigationTitle(L10n.playbackQuality)
}
}

View File

@ -74,9 +74,9 @@ struct SettingsView: View {
router.route(to: \.videoPlayerSettings)
}
ChevronButton(L10n.maximumBitrate)
ChevronButton(L10n.playbackQuality)
.onSelect {
router.route(to: \.maximumBitrateSettings)
router.route(to: \.playbackQualitySettings)
}
}
@ -86,11 +86,11 @@ struct SettingsView: View {
.onSelect {
router.route(to: \.customizeViewsSettings)
}
ChevronButton(L10n.experimental)
.onSelect {
router.route(to: \.experimentalSettings)
}
//
// ChevronButton(L10n.experimental)
// .onSelect {
// router.route(to: \.experimentalSettings)
// }
}
Section {

View File

@ -14,15 +14,44 @@
4E16FD572C01A32700110147 /* LetterPickerOrientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E16FD562C01A32700110147 /* LetterPickerOrientation.swift */; };
4E16FD582C01A32700110147 /* LetterPickerOrientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E16FD562C01A32700110147 /* LetterPickerOrientation.swift */; };
4E204E592C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E204E582C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift */; };
4E2AC4BE2C6C48D200DD600D /* CustomDeviceProfileAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4BD2C6C48D200DD600D /* CustomDeviceProfileAction.swift */; };
4E2AC4BF2C6C48D200DD600D /* CustomDeviceProfileAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4BD2C6C48D200DD600D /* CustomDeviceProfileAction.swift */; };
4E2AC4C22C6C491200DD600D /* AudoCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4C12C6C491200DD600D /* AudoCodec.swift */; };
4E2AC4C32C6C491200DD600D /* AudoCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4C12C6C491200DD600D /* AudoCodec.swift */; };
4E2AC4C52C6C492700DD600D /* MediaContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4C42C6C492700DD600D /* MediaContainer.swift */; };
4E2AC4C62C6C492700DD600D /* MediaContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4C42C6C492700DD600D /* MediaContainer.swift */; };
4E2AC4C82C6C493C00DD600D /* SubtitleFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4C72C6C493C00DD600D /* SubtitleFormat.swift */; };
4E2AC4C92C6C493C00DD600D /* SubtitleFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4C72C6C493C00DD600D /* SubtitleFormat.swift */; };
4E2AC4CB2C6C494E00DD600D /* VideoCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4CA2C6C494E00DD600D /* VideoCodec.swift */; };
4E2AC4CC2C6C494E00DD600D /* VideoCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4CA2C6C494E00DD600D /* VideoCodec.swift */; };
4E2AC4CF2C6C4A0600DD600D /* PlaybackQualitySettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4CD2C6C4A0600DD600D /* PlaybackQualitySettingsCoordinator.swift */; };
4E2AC4D42C6C4C1200DD600D /* OrderedSectionSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D32C6C4C1200DD600D /* OrderedSectionSelectorView.swift */; };
4E2AC4D62C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D52C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift */; };
4E2AC4D92C6C4D9400DD600D /* PlaybackQualitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */; };
4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */; };
4E71D6892C80910900A0174D /* EditCustomDeviceProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E71D6882C80910900A0174D /* EditCustomDeviceProfileView.swift */; };
4E73E2A62C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */; };
4E73E2A72C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */; };
4E73E2AE2C420207002D2A78 /* MaximumBitrateSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E73E2AD2C420207002D2A78 /* MaximumBitrateSettingsView.swift */; };
4E73E2B02C4211CA002D2A78 /* MaximumBitrateSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E73E2AF2C4211CA002D2A78 /* MaximumBitrateSettingsView.swift */; };
4E762AAE2C3A1A95004D1579 /* PlaybackBitrate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */; };
4E762AAF2C3A1A95004D1579 /* PlaybackBitrate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */; };
4E8B34EA2AB91B6E0018F305 /* ItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */; };
4E8B34EB2AB91B6E0018F305 /* ItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */; };
4E9A24E62C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24E52C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift */; };
4E9A24E82C82B6190023DA83 /* CustomProfileButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24E72C82B6190023DA83 /* CustomProfileButton.swift */; };
4E9A24E92C82B79D0023DA83 /* EditCustomDeviceProfileCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C8572C80332500E2879E /* EditCustomDeviceProfileCoordinator.swift */; };
4E9A24EB2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24EA2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift */; };
4E9A24ED2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift */; };
4EBE06462C7E9509004A6C03 /* PlaybackCompatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBE06452C7E9509004A6C03 /* PlaybackCompatibility.swift */; };
4EBE06472C7E9509004A6C03 /* PlaybackCompatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBE06452C7E9509004A6C03 /* PlaybackCompatibility.swift */; };
4EBE064D2C7EB6D3004A6C03 /* VideoPlayerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBE064C2C7EB6D3004A6C03 /* VideoPlayerType.swift */; };
4EBE064E2C7EB6D3004A6C03 /* VideoPlayerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBE064C2C7EB6D3004A6C03 /* VideoPlayerType.swift */; };
4EBE064F2C7ECE8D004A6C03 /* InlineEnumToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1549677296CB22B00C4EF88 /* InlineEnumToggle.swift */; };
4EBE06532C7ED0E1004A6C03 /* DeviceProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBE06502C7ED0E1004A6C03 /* DeviceProfile.swift */; };
4EBE06542C7ED0E1004A6C03 /* DeviceProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBE06502C7ED0E1004A6C03 /* DeviceProfile.swift */; };
4EC1C8522C7FDFA300E2879E /* PlaybackDeviceProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C8512C7FDFA300E2879E /* PlaybackDeviceProfile.swift */; };
4EC1C8532C7FDFA300E2879E /* PlaybackDeviceProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C8512C7FDFA300E2879E /* PlaybackDeviceProfile.swift */; };
4EC1C8692C808FBB00E2879E /* CustomDeviceProfileSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C8682C808FBB00E2879E /* CustomDeviceProfileSettingsView.swift */; };
4EC1C86D2C80903A00E2879E /* CustomProfileButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C86C2C80903A00E2879E /* CustomProfileButton.swift */; };
531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690E6267ABD79005D8AB9 /* HomeView.swift */; };
531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; };
5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */; };
@ -352,6 +381,7 @@
E13332942953BAA100EE76AB /* DownloadTaskContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13332932953BAA100EE76AB /* DownloadTaskContentView.swift */; };
E1356E0329A730B200382563 /* SeparatorHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1356E0129A7309D00382563 /* SeparatorHStack.swift */; };
E1356E0429A731EB00382563 /* SeparatorHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1356E0129A7309D00382563 /* SeparatorHStack.swift */; };
E1366A222C826DA700A36DED /* EditCustomDeviceProfileCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C8572C80332500E2879E /* EditCustomDeviceProfileCoordinator.swift */; };
E1388A42293F0AAD009721B1 /* PreferenceUIHostingSwizzling.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1388A40293F0AAD009721B1 /* PreferenceUIHostingSwizzling.swift */; };
E1388A43293F0AAD009721B1 /* PreferenceUIHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1388A41293F0AAD009721B1 /* PreferenceUIHostingController.swift */; };
E1388A46293F0ABA009721B1 /* SwizzleSwift in Frameworks */ = {isa = PBXBuildFile; productRef = E1388A45293F0ABA009721B1 /* SwizzleSwift */; };
@ -458,7 +488,6 @@
E157563029355B7900976E1F /* UpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E157562F29355B7900976E1F /* UpdateView.swift */; };
E15756322935642A00976E1F /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15756312935642A00976E1F /* Double.swift */; };
E15756342936851D00976E1F /* NativeVideoPlayerSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15756332936851D00976E1F /* NativeVideoPlayerSettingsView.swift */; };
E15756362936856700976E1F /* VideoPlayerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15756352936856700976E1F /* VideoPlayerType.swift */; };
E1575E3C293C6B15001665B1 /* Files in Frameworks */ = {isa = PBXBuildFile; productRef = E1575E3B293C6B15001665B1 /* Files */; };
E1575E56293E7650001665B1 /* VLCUI in Frameworks */ = {isa = PBXBuildFile; productRef = E1575E55293E7650001665B1 /* VLCUI */; };
E1575E58293E7685001665B1 /* Files in Frameworks */ = {isa = PBXBuildFile; productRef = E1575E57293E7685001665B1 /* Files */; };
@ -481,7 +510,6 @@
E1575E72293E77B5001665B1 /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D8429229340B8300D1041A /* Utilities.swift */; };
E1575E74293E77B5001665B1 /* PanDirectionGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C925F62887504B002A7A66 /* PanDirectionGestureRecognizer.swift */; };
E1575E75293E77B5001665B1 /* LibraryDisplayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F05EB28BC9000003499D2 /* LibraryDisplayType.swift */; };
E1575E76293E77B5001665B1 /* VideoPlayerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15756352936856700976E1F /* VideoPlayerType.swift */; };
E1575E78293E77B5001665B1 /* TrailingTimestampType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C8CE7B28FF015000DF5D7B /* TrailingTimestampType.swift */; };
E1575E7A293E77B5001665B1 /* TimeStampType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E129428F28F0BDC300796AC6 /* TimeStampType.swift */; };
E1575E7C293E77B5001665B1 /* TimerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E306CC28EF6E8000537998 /* TimerProxy.swift */; };
@ -652,6 +680,11 @@
E18E021E2887492B0022598C /* RowDivider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01FF288749200022598C /* RowDivider.swift */; };
E18E021F2887492B0022598C /* SystemImageContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */; };
E18E02232887492B0022598C /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; };
E19070492C84F2BB0004600E /* ButtonStyle-iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19070482C84F2BB0004600E /* ButtonStyle-iOS.swift */; };
E190704C2C858CEB0004600E /* VideoPlayerType+Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = E190704B2C858CEB0004600E /* VideoPlayerType+Shared.swift */; };
E190704D2C858CEB0004600E /* VideoPlayerType+Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = E190704B2C858CEB0004600E /* VideoPlayerType+Shared.swift */; };
E190704F2C8592B40004600E /* PlaybackCompatibility+Video.swift in Sources */ = {isa = PBXBuildFile; fileRef = E190704E2C8592B40004600E /* PlaybackCompatibility+Video.swift */; };
E19070502C8592B40004600E /* PlaybackCompatibility+Video.swift in Sources */ = {isa = PBXBuildFile; fileRef = E190704E2C8592B40004600E /* PlaybackCompatibility+Video.swift */; };
E1921B7428E61914003A5238 /* SpecialFeatureHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1921B7328E61914003A5238 /* SpecialFeatureHStack.swift */; };
E1921B7628E63306003A5238 /* GestureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1921B7528E63306003A5238 /* GestureView.swift */; };
E192608328D2D0DB002314B4 /* Factory in Frameworks */ = {isa = PBXBuildFile; productRef = E192608228D2D0DB002314B4 /* Factory */; };
@ -786,6 +819,22 @@
E1CAF6602BA345830087D991 /* MediaViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CAF65B2BA345830087D991 /* MediaViewModel.swift */; };
E1CAF6622BA363840087D991 /* UIHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CAF6612BA363840087D991 /* UIHostingController.swift */; };
E1CAF6632BA363840087D991 /* UIHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CAF6612BA363840087D991 /* UIHostingController.swift */; };
E1CB756F2C80E66700217C76 /* CommaStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CB756E2C80E66700217C76 /* CommaStringBuilder.swift */; };
E1CB75702C80E66700217C76 /* CommaStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CB756E2C80E66700217C76 /* CommaStringBuilder.swift */; };
E1CB75722C80E71800217C76 /* DirectPlayProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CB75712C80E71800217C76 /* DirectPlayProfile.swift */; };
E1CB75732C80E71800217C76 /* DirectPlayProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CB75712C80E71800217C76 /* DirectPlayProfile.swift */; };
E1CB75752C80EAFA00217C76 /* ArrayBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CB75742C80EAFA00217C76 /* ArrayBuilder.swift */; };
E1CB75762C80EAFA00217C76 /* ArrayBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CB75742C80EAFA00217C76 /* ArrayBuilder.swift */; };
E1CB75782C80ECF100217C76 /* VideoPlayerType+Native.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CB75772C80ECF100217C76 /* VideoPlayerType+Native.swift */; };
E1CB75792C80ECF100217C76 /* VideoPlayerType+Native.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CB75772C80ECF100217C76 /* VideoPlayerType+Native.swift */; };
E1CB757C2C80F00D00217C76 /* TranscodingProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CB757B2C80F00D00217C76 /* TranscodingProfile.swift */; };
E1CB757D2C80F00D00217C76 /* TranscodingProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CB757B2C80F00D00217C76 /* TranscodingProfile.swift */; };
E1CB757F2C80F28F00217C76 /* SubtitleProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CB757E2C80F28F00217C76 /* SubtitleProfile.swift */; };
E1CB75802C80F28F00217C76 /* SubtitleProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CB757E2C80F28F00217C76 /* SubtitleProfile.swift */; };
E1CB75822C80F66900217C76 /* VideoPlayerType+Swiftfin.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CB75812C80F66900217C76 /* VideoPlayerType+Swiftfin.swift */; };
E1CB75832C80F66900217C76 /* VideoPlayerType+Swiftfin.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CB75812C80F66900217C76 /* VideoPlayerType+Swiftfin.swift */; };
E1CB758B2C80F9EC00217C76 /* CodecProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CB758A2C80F9EC00217C76 /* CodecProfile.swift */; };
E1CB758C2C80F9EC00217C76 /* CodecProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CB758A2C80F9EC00217C76 /* CodecProfile.swift */; };
E1CCF12E28ABF989006CAC9E /* PosterDisplayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CCF12D28ABF989006CAC9E /* PosterDisplayType.swift */; };
E1CCF13128AC07EC006CAC9E /* PosterHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */; };
E1CD13EF28EF364100CB46CA /* DetectOrientationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CD13EE28EF364100CB46CA /* DetectOrientationModifier.swift */; };
@ -798,14 +847,6 @@
E1D37F492B9C648E00343D2B /* MaxHeightText.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F472B9C648E00343D2B /* MaxHeightText.swift */; };
E1D37F4B2B9CEA5C00343D2B /* ImageSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F4A2B9CEA5C00343D2B /* ImageSource.swift */; };
E1D37F4C2B9CEA5C00343D2B /* ImageSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F4A2B9CEA5C00343D2B /* ImageSource.swift */; };
E1D37F4E2B9CEDC400343D2B /* DeviceProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F4D2B9CEDC400343D2B /* DeviceProfile.swift */; };
E1D37F4F2B9CEDC400343D2B /* DeviceProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F4D2B9CEDC400343D2B /* DeviceProfile.swift */; };
E1D37F522B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F512B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift */; };
E1D37F532B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F512B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift */; };
E1D37F552B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F542B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift */; };
E1D37F562B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F542B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift */; };
E1D37F582B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F572B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift */; };
E1D37F592B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D37F572B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift */; };
E1D4BF7C2719D05000A11E64 /* AppSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF7B2719D05000A11E64 /* AppSettingsView.swift */; };
E1D4BF812719D22800A11E64 /* AppAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF802719D22800A11E64 /* AppAppearance.swift */; };
E1D4BF8A2719D3D000A11E64 /* AppSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF892719D3D000A11E64 /* AppSettingsCoordinator.swift */; };
@ -942,12 +983,31 @@
4E16FD522C01840C00110147 /* LetterPickerBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterPickerBar.swift; sourceTree = "<group>"; };
4E16FD562C01A32700110147 /* LetterPickerOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterPickerOrientation.swift; sourceTree = "<group>"; };
4E204E582C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeSettingsCoordinator.swift; sourceTree = "<group>"; };
4E2AC4BD2C6C48D200DD600D /* CustomDeviceProfileAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileAction.swift; sourceTree = "<group>"; };
4E2AC4C12C6C491200DD600D /* AudoCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudoCodec.swift; sourceTree = "<group>"; };
4E2AC4C42C6C492700DD600D /* MediaContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaContainer.swift; sourceTree = "<group>"; };
4E2AC4C72C6C493C00DD600D /* SubtitleFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtitleFormat.swift; sourceTree = "<group>"; };
4E2AC4CA2C6C494E00DD600D /* VideoCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoCodec.swift; sourceTree = "<group>"; };
4E2AC4CD2C6C4A0600DD600D /* PlaybackQualitySettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsCoordinator.swift; sourceTree = "<group>"; };
4E2AC4D32C6C4C1200DD600D /* OrderedSectionSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderedSectionSelectorView.swift; sourceTree = "<group>"; };
4E2AC4D52C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsView.swift; sourceTree = "<group>"; };
4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsView.swift; sourceTree = "<group>"; };
4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = "<group>"; };
4E71D6882C80910900A0174D /* EditCustomDeviceProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileView.swift; sourceTree = "<group>"; };
4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackBitrateTestSize.swift; sourceTree = "<group>"; };
4E73E2AD2C420207002D2A78 /* MaximumBitrateSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaximumBitrateSettingsView.swift; sourceTree = "<group>"; };
4E73E2AF2C4211CA002D2A78 /* MaximumBitrateSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaximumBitrateSettingsView.swift; sourceTree = "<group>"; };
4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackBitrate.swift; sourceTree = "<group>"; };
4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemFilter.swift; sourceTree = "<group>"; };
4E9A24E52C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileSettingsView.swift; sourceTree = "<group>"; };
4E9A24E72C82B6190023DA83 /* CustomProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomProfileButton.swift; sourceTree = "<group>"; };
4E9A24EA2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileCoordinator.swift; sourceTree = "<group>"; };
4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileView.swift; sourceTree = "<group>"; };
4EBE06452C7E9509004A6C03 /* PlaybackCompatibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackCompatibility.swift; sourceTree = "<group>"; };
4EBE064C2C7EB6D3004A6C03 /* VideoPlayerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerType.swift; sourceTree = "<group>"; };
4EBE06502C7ED0E1004A6C03 /* DeviceProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceProfile.swift; sourceTree = "<group>"; };
4EC1C8512C7FDFA300E2879E /* PlaybackDeviceProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackDeviceProfile.swift; sourceTree = "<group>"; };
4EC1C8572C80332500E2879E /* EditCustomDeviceProfileCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileCoordinator.swift; sourceTree = "<group>"; };
4EC1C8682C808FBB00E2879E /* CustomDeviceProfileSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileSettingsView.swift; sourceTree = "<group>"; };
4EC1C86C2C80903A00E2879E /* CustomProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomProfileButton.swift; sourceTree = "<group>"; };
531690E6267ABD79005D8AB9 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
531AC8BE26750DE20091C7EB /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = "<group>"; };
5321753A2671BCFC005491E6 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
@ -1263,7 +1323,6 @@
E157562F29355B7900976E1F /* UpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateView.swift; sourceTree = "<group>"; };
E15756312935642A00976E1F /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = "<group>"; };
E15756332936851D00976E1F /* NativeVideoPlayerSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeVideoPlayerSettingsView.swift; sourceTree = "<group>"; };
E15756352936856700976E1F /* VideoPlayerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerType.swift; sourceTree = "<group>"; };
E1575EA5293E7D40001665B1 /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = "<group>"; };
E1579EA62B97DC1500A31CA1 /* Eventful.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Eventful.swift; sourceTree = "<group>"; };
E1581E26291EF59800D6C640 /* SplitContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitContentView.swift; sourceTree = "<group>"; };
@ -1368,6 +1427,9 @@
E18E01FF288749200022598C /* RowDivider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RowDivider.swift; sourceTree = "<group>"; };
E18E0202288749200022598C /* AttributeStyleModifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttributeStyleModifier.swift; sourceTree = "<group>"; };
E18E0203288749200022598C /* BlurView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurView.swift; sourceTree = "<group>"; };
E19070482C84F2BB0004600E /* ButtonStyle-iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ButtonStyle-iOS.swift"; sourceTree = "<group>"; };
E190704B2C858CEB0004600E /* VideoPlayerType+Shared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VideoPlayerType+Shared.swift"; sourceTree = "<group>"; };
E190704E2C8592B40004600E /* PlaybackCompatibility+Video.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlaybackCompatibility+Video.swift"; sourceTree = "<group>"; };
E1921B7328E61914003A5238 /* SpecialFeatureHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialFeatureHStack.swift; sourceTree = "<group>"; };
E1921B7528E63306003A5238 /* GestureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureView.swift; sourceTree = "<group>"; };
E1937A3A288E54AD00CB80AA /* BaseItemDto+Images.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+Images.swift"; sourceTree = "<group>"; };
@ -1458,6 +1520,14 @@
E1CAF65A2BA345830087D991 /* MediaType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaType.swift; sourceTree = "<group>"; };
E1CAF65B2BA345830087D991 /* MediaViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaViewModel.swift; sourceTree = "<group>"; };
E1CAF6612BA363840087D991 /* UIHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIHostingController.swift; sourceTree = "<group>"; };
E1CB756E2C80E66700217C76 /* CommaStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommaStringBuilder.swift; sourceTree = "<group>"; };
E1CB75712C80E71800217C76 /* DirectPlayProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectPlayProfile.swift; sourceTree = "<group>"; };
E1CB75742C80EAFA00217C76 /* ArrayBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayBuilder.swift; sourceTree = "<group>"; };
E1CB75772C80ECF100217C76 /* VideoPlayerType+Native.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VideoPlayerType+Native.swift"; sourceTree = "<group>"; };
E1CB757B2C80F00D00217C76 /* TranscodingProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodingProfile.swift; sourceTree = "<group>"; };
E1CB757E2C80F28F00217C76 /* SubtitleProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtitleProfile.swift; sourceTree = "<group>"; };
E1CB75812C80F66900217C76 /* VideoPlayerType+Swiftfin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VideoPlayerType+Swiftfin.swift"; sourceTree = "<group>"; };
E1CB758A2C80F9EC00217C76 /* CodecProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodecProfile.swift; sourceTree = "<group>"; };
E1CCF12D28ABF989006CAC9E /* PosterDisplayType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterDisplayType.swift; sourceTree = "<group>"; };
E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterHStack.swift; sourceTree = "<group>"; };
E1CD13EE28EF364100CB46CA /* DetectOrientationModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectOrientationModifier.swift; sourceTree = "<group>"; };
@ -1468,10 +1538,6 @@
E1D3044328D1991900587289 /* LibraryViewTypeToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewTypeToggle.swift; sourceTree = "<group>"; };
E1D37F472B9C648E00343D2B /* MaxHeightText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaxHeightText.swift; sourceTree = "<group>"; };
E1D37F4A2B9CEA5C00343D2B /* ImageSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSource.swift; sourceTree = "<group>"; };
E1D37F4D2B9CEDC400343D2B /* DeviceProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProfile.swift; sourceTree = "<group>"; };
E1D37F512B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceProfile+SharedCodecProfiles.swift"; sourceTree = "<group>"; };
E1D37F542B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceProfile+NativeProfile.swift"; sourceTree = "<group>"; };
E1D37F572B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceProfile+SwiftfinProfile.swift"; sourceTree = "<group>"; };
E1D4BF7B2719D05000A11E64 /* AppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsView.swift; sourceTree = "<group>"; };
E1D4BF802719D22800A11E64 /* AppAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAppearance.swift; sourceTree = "<group>"; };
E1D4BF892719D3D000A11E64 /* AppSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsCoordinator.swift; sourceTree = "<group>"; };
@ -1689,6 +1755,17 @@
path = Components;
sourceTree = "<group>";
};
4E2AC4C02C6C48EB00DD600D /* MediaComponents */ = {
isa = PBXGroup;
children = (
4E2AC4C12C6C491200DD600D /* AudoCodec.swift */,
4E2AC4C42C6C492700DD600D /* MediaContainer.swift */,
4E2AC4C72C6C493C00DD600D /* SubtitleFormat.swift */,
4E2AC4CA2C6C494E00DD600D /* VideoCodec.swift */,
);
path = MediaComponents;
sourceTree = "<group>";
};
4E3A785D2C3B87A400D33C11 /* PlaybackBitrate */ = {
isa = PBXGroup;
children = (
@ -1698,6 +1775,42 @@
path = PlaybackBitrate;
sourceTree = "<group>";
};
4E9A24E32C82B4700023DA83 /* CustomDeviceProfileSettingsView */ = {
isa = PBXGroup;
children = (
4E9A24E42C82B5440023DA83 /* Components */,
4E9A24E52C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift */,
);
path = CustomDeviceProfileSettingsView;
sourceTree = "<group>";
};
4E9A24E42C82B5440023DA83 /* Components */ = {
isa = PBXGroup;
children = (
4E9A24E72C82B6190023DA83 /* CustomProfileButton.swift */,
4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift */,
);
path = Components;
sourceTree = "<group>";
};
4EC1C86A2C80900B00E2879E /* CustomDeviceProfileSettingsView */ = {
isa = PBXGroup;
children = (
4EC1C86B2C80902200E2879E /* Components */,
4EC1C8682C808FBB00E2879E /* CustomDeviceProfileSettingsView.swift */,
);
path = CustomDeviceProfileSettingsView;
sourceTree = "<group>";
};
4EC1C86B2C80902200E2879E /* Components */ = {
isa = PBXGroup;
children = (
4EC1C86C2C80903A00E2879E /* CustomProfileButton.swift */,
4E71D6882C80910900A0174D /* EditCustomDeviceProfileView.swift */,
);
path = Components;
sourceTree = "<group>";
};
5310694F2684E7EE00CFFDBA /* VideoPlayer */ = {
isa = PBXGroup;
children = (
@ -1823,9 +1936,12 @@
isa = PBXGroup;
children = (
E1D4BF802719D22800A11E64 /* AppAppearance.swift */,
E1CB75742C80EAFA00217C76 /* ArrayBuilder.swift */,
E11562942C818CB2001D5DE4 /* BindingBox.swift */,
E129429728F4785200796AC6 /* CaseIterablePicker.swift */,
E10231432BCF8A51009D71FC /* ChannelProgram.swift */,
E1CB756E2C80E66700217C76 /* CommaStringBuilder.swift */,
4E2AC4BD2C6C48D200DD600D /* CustomDeviceProfileAction.swift */,
E17FB55128C119D400311DFE /* Displayable.swift */,
E1579EA62B97DC1500A31CA1 /* Eventful.swift */,
E1092F4B29106F9F00163F57 /* GestureAction.swift */,
@ -1834,9 +1950,12 @@
E1C925F328875037002A7A66 /* ItemViewType.swift */,
E13F05EB28BC9000003499D2 /* LibraryDisplayType.swift */,
E1DE2B4E2B983F3200F6715F /* LibraryParent */,
4E2AC4C02C6C48EB00DD600D /* MediaComponents */,
E1AA331E2782639D00F6439C /* OverlayType.swift */,
E1C925F62887504B002A7A66 /* PanDirectionGestureRecognizer.swift */,
4E3A785D2C3B87A400D33C11 /* PlaybackBitrate */,
E190704A2C858B7B0004600E /* PlaybackCompatibility */,
4EC1C8512C7FDFA300E2879E /* PlaybackDeviceProfile.swift */,
E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */,
E1937A60288F32DB00CB80AA /* Poster.swift */,
E1CCF12D28ABF989006CAC9E /* PosterDisplayType.swift */,
@ -1845,7 +1964,6 @@
E18ACA8A2A14301800BB4F35 /* ScalingButtonStyle.swift */,
E164A7F52BE4814700A54B18 /* SelectUserServerSelection.swift */,
E129429228F2845000796AC6 /* SliderType.swift */,
E1DA654B28E69B0500592A73 /* SpecialFeatureType.swift */,
E11042742B8013DF00821020 /* Stateful.swift */,
E149CCAC2BE6ECC8008B9331 /* Storable.swift */,
E1EF4C402911B783008CC695 /* StreamType.swift */,
@ -1860,7 +1978,7 @@
E1D8429229340B8300D1041A /* Utilities.swift */,
E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */,
E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */,
E15756352936856700976E1F /* VideoPlayerType.swift */,
E1CB757A2C80EF9D00217C76 /* VideoPlayerType */,
);
path = Objects;
sourceTree = "<group>";
@ -1878,6 +1996,7 @@
E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */,
E1763A632BF3C9AA004DF6AB /* ListRowButton.swift */,
E10E842B29A589860064EA49 /* NonePosterButton.swift */,
4E2AC4D32C6C4C1200DD600D /* OrderedSectionSelectorView.swift */,
E111D8F928D0400900400001 /* PagingLibraryView.swift */,
E1C92617288756BD002A7A66 /* PosterButton.swift */,
E1C92619288756BD002A7A66 /* PosterHStack.swift */,
@ -2191,8 +2310,10 @@
E1D4BF892719D3D000A11E64 /* AppSettingsCoordinator.swift */,
E154967D296CCB6C00C4EF88 /* BasicNavigationCoordinator.swift */,
4E204E582C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift */,
4E9A24EA2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift */,
E17AC9702954F636003D2BC2 /* DownloadListCoordinator.swift */,
E13332902953B91000EE76AB /* DownloadTaskCoordinator.swift */,
4EC1C8572C80332500E2879E /* EditCustomDeviceProfileCoordinator.swift */,
6220D0B926D6092100B8E046 /* FilterCoordinator.swift */,
62C29EA526D1036A00C1D2E7 /* HomeCoordinator.swift */,
6220D0BF26D61C5000B8E046 /* ItemCoordinator.swift */,
@ -2202,6 +2323,7 @@
E193D5412719404B00900D82 /* MainCoordinator */,
62C29EA726D103D500C1D2E7 /* MediaCoordinator.swift */,
E170D106294D23BA0017224C /* MediaSourceInfoCoordinator.swift */,
4E2AC4CD2C6C4A0600DD600D /* PlaybackQualitySettingsCoordinator.swift */,
E1A1528F28FD23D600600579 /* PlaybackSettingsCoordinator.swift */,
6220D0B626D5EE1100B8E046 /* SearchCoordinator.swift */,
E13DD4012717EE79009D4DAF /* SelectUserCoordinator.swift */,
@ -2564,6 +2686,7 @@
E11CEB85289984F5003E74C7 /* Extensions */ = {
isa = PBXGroup;
children = (
E19070482C84F2BB0004600E /* ButtonStyle-iOS.swift */,
E1A3E4CC2BB7D8C8005C59F8 /* Label-iOS.swift */,
E11CEB8828998522003E74C7 /* View */,
);
@ -3153,6 +3276,15 @@
path = Components;
sourceTree = "<group>";
};
E190704A2C858B7B0004600E /* PlaybackCompatibility */ = {
isa = PBXGroup;
children = (
4EBE06452C7E9509004A6C03 /* PlaybackCompatibility.swift */,
E190704E2C8592B40004600E /* PlaybackCompatibility+Video.swift */,
);
path = PlaybackCompatibility;
sourceTree = "<group>";
};
E193D5412719404B00900D82 /* MainCoordinator */ = {
isa = PBXGroup;
children = (
@ -3219,7 +3351,9 @@
E1D37F5B2B9CF02600343D2B /* BaseItemDto */,
E1D37F5A2B9CF01F00343D2B /* BaseItemPerson */,
E1002B632793CEE700E47059 /* ChapterInfo.swift */,
E1D37F502B9CEF1300343D2B /* DeviceProfile */,
E1CB758A2C80F9EC00217C76 /* CodecProfile.swift */,
4EBE06502C7ED0E1004A6C03 /* DeviceProfile.swift */,
E1CB75712C80E71800217C76 /* DirectPlayProfile.swift */,
E1722DB029491C3900CC0239 /* ImageBlurHashes.swift */,
E1D842902933F87500D1041A /* ItemFields.swift */,
E148128728C154BF003B8787 /* ItemFilter+ItemTrait.swift */,
@ -3229,6 +3363,9 @@
E122A9122788EAAD0060FA63 /* MediaStream.swift */,
E1AD105E26D9ADDD003E4A08 /* NameGuidPair.swift */,
E148128428C15472003B8787 /* SortOrder+ItemSortOrder.swift */,
E1DA654B28E69B0500592A73 /* SpecialFeatureType.swift */,
E1CB757E2C80F28F00217C76 /* SubtitleProfile.swift */,
E1CB757B2C80F00D00217C76 /* TranscodingProfile.swift */,
E18CE0B128A229E70092E7F1 /* UserDto.swift */,
);
path = JellyfinAPI;
@ -3373,15 +3510,15 @@
path = MediaViewModel;
sourceTree = "<group>";
};
E1D37F502B9CEF1300343D2B /* DeviceProfile */ = {
E1CB757A2C80EF9D00217C76 /* VideoPlayerType */ = {
isa = PBXGroup;
children = (
E1D37F4D2B9CEDC400343D2B /* DeviceProfile.swift */,
E1D37F542B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift */,
E1D37F512B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift */,
E1D37F572B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift */,
4EBE064C2C7EB6D3004A6C03 /* VideoPlayerType.swift */,
E1CB75772C80ECF100217C76 /* VideoPlayerType+Native.swift */,
E190704B2C858CEB0004600E /* VideoPlayerType+Shared.swift */,
E1CB75812C80F66900217C76 /* VideoPlayerType+Swiftfin.swift */,
);
path = DeviceProfile;
path = VideoPlayerType;
sourceTree = "<group>";
};
E1D37F5A2B9CF01F00343D2B /* BaseItemPerson */ = {
@ -3485,13 +3622,14 @@
E1E5D54A2783E26100692DFE /* SettingsView */ = {
isa = PBXGroup;
children = (
4EC1C86A2C80900B00E2879E /* CustomDeviceProfileSettingsView */,
4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */,
E175AFF2299AC117004DCF52 /* DebugSettingsView.swift */,
E1E5D54B2783E27200692DFE /* ExperimentalSettingsView.swift */,
E104C86F296E087200C1C3F9 /* IndicatorSettingsView.swift */,
E16AF11B292C98A7001422A8 /* GestureSettingsView.swift */,
4E73E2AD2C420207002D2A78 /* MaximumBitrateSettingsView.swift */,
E15756332936851D00976E1F /* NativeVideoPlayerSettingsView.swift */,
4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */,
E1545BD62BDC559500D9578F /* UserProfileSettingsView */,
E1BE1CEB2BDB68BC008176A9 /* SettingsView */,
E1BDF2E7295148F400CC0294 /* VideoPlayerSettingsView */,
@ -3502,10 +3640,11 @@
E1E5D54D2783E66600692DFE /* SettingsView */ = {
isa = PBXGroup;
children = (
4E9A24E32C82B4700023DA83 /* CustomDeviceProfileSettingsView */,
E1CEFBF627914E6400F60429 /* CustomizeViewsSettings.swift */,
E1E5D5502783E67700692DFE /* ExperimentalSettingsView.swift */,
E104C872296E0D0A00C1C3F9 /* IndicatorSettingsView.swift */,
4E73E2AF2C4211CA002D2A78 /* MaximumBitrateSettingsView.swift */,
4E2AC4D52C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift */,
5398514426B64DA100101B49 /* SettingsView.swift */,
E1549679296CB4B000C4EF88 /* VideoPlayerSettingsView.swift */,
);
@ -3918,8 +4057,9 @@
E193D53327193F7D00900D82 /* FilterCoordinator.swift in Sources */,
E18E021E2887492B0022598C /* RowDivider.swift in Sources */,
E1DC983E296DEB9B00982F06 /* UnwatchedIndicator.swift in Sources */,
4E2AC4BF2C6C48D200DD600D /* CustomDeviceProfileAction.swift in Sources */,
4EBE06472C7E9509004A6C03 /* PlaybackCompatibility.swift in Sources */,
E107BB9427880A8F00354E07 /* CollectionItemViewModel.swift in Sources */,
E1D37F592B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift in Sources */,
53ABFDE9267974EF00886593 /* HomeViewModel.swift in Sources */,
E1575E99293E7B1E001665B1 /* UIColor.swift in Sources */,
E1575E92293E7B1E001665B1 /* CGSize.swift in Sources */,
@ -3931,6 +4071,7 @@
E10B1ED12BD9AFF200A92EAF /* V2UserModel.swift in Sources */,
E18A8E7E28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift in Sources */,
E1E6C43B29AECBD30064123F /* BottomBarView.swift in Sources */,
E190704C2C858CEB0004600E /* VideoPlayerType+Shared.swift in Sources */,
E152107D2947ACA000375CC2 /* InvertedLightAppIcon.swift in Sources */,
E1549663296CA2EF00C4EF88 /* UserSession.swift in Sources */,
531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */,
@ -3941,12 +4082,12 @@
E187A60529AD2E25008387E6 /* StepperView.swift in Sources */,
E1575E71293E77B5001665B1 /* RepeatingTimer.swift in Sources */,
E1D4BF8B2719D3D000A11E64 /* AppSettingsCoordinator.swift in Sources */,
4E73E2B02C4211CA002D2A78 /* MaximumBitrateSettingsView.swift in Sources */,
E10231452BCF8A51009D71FC /* ChannelProgram.swift in Sources */,
E146A9D92BE6E9830034DA1E /* StoredValue.swift in Sources */,
E13DD3FA2717E961009D4DAF /* SelectUserViewModel.swift in Sources */,
C40CD926271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift in Sources */,
E1575E63293E77B5001665B1 /* CaseIterablePicker.swift in Sources */,
E1CB757F2C80F28F00217C76 /* SubtitleProfile.swift in Sources */,
E1E0BEB829EF450B0002E8D3 /* UIGestureRecognizer.swift in Sources */,
E193D53527193F8100900D82 /* ItemCoordinator.swift in Sources */,
E1C926162887565C002A7A66 /* SeriesItemView.swift in Sources */,
@ -3967,11 +4108,13 @@
E1549665296CA2EF00C4EF88 /* SwiftfinStore.swift in Sources */,
E1E9EFEB28C7EA2C00CC1F8B /* UserDto.swift in Sources */,
E1546777289AF46E00087E35 /* CollectionItemView.swift in Sources */,
4E2AC4C32C6C491200DD600D /* AudoCodec.swift in Sources */,
E1575EA6293E7D40001665B1 /* VideoPlayer.swift in Sources */,
E185920628CDAA6400326F80 /* CastAndCrewHStack.swift in Sources */,
E1A42E4F28CBD3E100A14DCB /* HomeErrorView.swift in Sources */,
53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */,
E122A9142788EAAD0060FA63 /* MediaStream.swift in Sources */,
4E2AC4D62C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift in Sources */,
E102314E2BCF8A7E009D71FC /* AlternateLayoutView.swift in Sources */,
E1575E74293E77B5001665B1 /* PanDirectionGestureRecognizer.swift in Sources */,
E1575E85293E7A00001665B1 /* DarkAppIcon.swift in Sources */,
@ -3983,11 +4126,13 @@
E1575E95293E7B1E001665B1 /* Font.swift in Sources */,
E11C15362BF7C505006BC9B6 /* UserProfileImageCoordinator.swift in Sources */,
E172D3AE2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */,
4EC1C8532C7FDFA300E2879E /* PlaybackDeviceProfile.swift in Sources */,
E11E374D293E7EC9009EF240 /* ItemFields.swift in Sources */,
E1575E6E293E77B5001665B1 /* SpecialFeatureType.swift in Sources */,
E12CC1C528D12D9B00678D5D /* SeeAllPosterButton.swift in Sources */,
E18A8E8128D6083700333B9A /* MediaSourceInfo+ItemVideoPlayerViewModel.swift in Sources */,
E1002B652793CEE800E47059 /* ChapterInfo.swift in Sources */,
4E9A24E62C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift in Sources */,
E1153D9A2BBA3E9800424D36 /* ErrorCard.swift in Sources */,
E1ED91162B95897500802036 /* LatestInLibraryViewModel.swift in Sources */,
E12376B32A33DFAC001F5B44 /* ItemOverviewView.swift in Sources */,
@ -4003,25 +4148,29 @@
E158C8D12A31947500C527C5 /* MediaSourceInfoView.swift in Sources */,
E11BDF782B8513B40045C54A /* ItemGenre.swift in Sources */,
E14EA16A2BF7333B00DE757A /* UserProfileImageViewModel.swift in Sources */,
4EBE06542C7ED0E1004A6C03 /* DeviceProfile.swift in Sources */,
E1575E98293E7B1E001665B1 /* UIApplication.swift in Sources */,
4E2AC4C92C6C493C00DD600D /* SubtitleFormat.swift in Sources */,
E12376B12A33DB33001F5B44 /* MediaSourceInfoCoordinator.swift in Sources */,
4E73E2A72C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift in Sources */,
E17885A4278105170094FBCF /* SFSymbolButton.swift in Sources */,
E13DD3ED27178A54009D4DAF /* UserSignInViewModel.swift in Sources */,
E1763A272BF303C9004DF6AB /* ServerSelectionMenu.swift in Sources */,
4E9A24E92C82B79D0023DA83 /* EditCustomDeviceProfileCoordinator.swift in Sources */,
E1C9261C288756BD002A7A66 /* PosterHStack.swift in Sources */,
E1722DB229491C3900CC0239 /* ImageBlurHashes.swift in Sources */,
E12E30F329638B140022FAC9 /* ChevronButton.swift in Sources */,
4E9A24ED2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift in Sources */,
E12CC1BC28D11E1000678D5D /* RecentlyAddedViewModel.swift in Sources */,
E1575E9E293E7B1E001665B1 /* Equatable.swift in Sources */,
E1C9261A288756BD002A7A66 /* PosterButton.swift in Sources */,
E1CB75782C80ECF100217C76 /* VideoPlayerType+Native.swift in Sources */,
E1575E5F293E77B5001665B1 /* StreamType.swift in Sources */,
E1803EA22BFBD6CF0039F90E /* Hashable.swift in Sources */,
E1388A42293F0AAD009721B1 /* PreferenceUIHostingSwizzling.swift in Sources */,
E1575E93293E7B1E001665B1 /* Double.swift in Sources */,
E1B5784228F8AFCB00D42911 /* WrappedView.swift in Sources */,
E11895AA289383BC0042947B /* ScrollViewOffsetModifier.swift in Sources */,
E1575E76293E77B5001665B1 /* VideoPlayerType.swift in Sources */,
E10B1EBF2BD9AD5C00A92EAF /* V1ServerModel.swift in Sources */,
E17AC96B2954D00E003D2BC2 /* URLResponse.swift in Sources */,
E1763A2B2BF3046E004DF6AB /* UserGridButton.swift in Sources */,
@ -4052,6 +4201,7 @@
E1C926122887565C002A7A66 /* SeriesItemContentView.swift in Sources */,
E193D53727193F8700900D82 /* MediaCoordinator.swift in Sources */,
E12CC1CB28D1333400678D5D /* CinematicResumeItemView.swift in Sources */,
4E2AC4D42C6C4C1200DD600D /* OrderedSectionSelectorView.swift in Sources */,
E1575E9C293E7B1E001665B1 /* Collection.swift in Sources */,
E1C9260F2887565C002A7A66 /* AttributeHStack.swift in Sources */,
E11CEB9428999D9E003E74C7 /* EpisodeItemContentView.swift in Sources */,
@ -4060,6 +4210,7 @@
E12A9EF929499E0100731C3A /* JellyfinClient.swift in Sources */,
E148128328C1443D003B8787 /* NameGuidPair.swift in Sources */,
E1579EA82B97DC1500A31CA1 /* Eventful.swift in Sources */,
4E9A24EB2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift in Sources */,
E185920828CDAAA200326F80 /* SimilarItemsHStack.swift in Sources */,
E10E842C29A589860064EA49 /* NonePosterButton.swift in Sources */,
E1575E5C293E77B5001665B1 /* PlaybackSpeed.swift in Sources */,
@ -4073,7 +4224,6 @@
E148128928C154BF003B8787 /* ItemFilter+ItemTrait.swift in Sources */,
E43918672AD5C8310045A18C /* OnScenePhaseChangedModifier.swift in Sources */,
E154966F296CA2EF00C4EF88 /* LogManager.swift in Sources */,
E1D37F562B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift in Sources */,
E1575E75293E77B5001665B1 /* LibraryDisplayType.swift in Sources */,
E193D53427193F7F00900D82 /* HomeCoordinator.swift in Sources */,
E193D5502719430400900D82 /* ServerDetailView.swift in Sources */,
@ -4092,6 +4242,7 @@
E1937A3C288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */,
E18A17F0298C68B700C22F62 /* Overlay.swift in Sources */,
E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */,
4E2AC4CF2C6C4A0600DD600D /* PlaybackQualitySettingsCoordinator.swift in Sources */,
E1D37F492B9C648E00343D2B /* MaxHeightText.swift in Sources */,
E146A9DC2BE6E9BF0034DA1E /* StoredValues+User.swift in Sources */,
E154677A289AF48200087E35 /* CollectionItemContentView.swift in Sources */,
@ -4110,6 +4261,7 @@
E164A7F72BE4816500A54B18 /* SelectUserServerSelection.swift in Sources */,
E1575E84293E7A00001665B1 /* PrimaryAppIcon.swift in Sources */,
E1153DCD2BBB633B00424D36 /* FastSVGView.swift in Sources */,
E1CB75762C80EAFA00217C76 /* ArrayBuilder.swift in Sources */,
E102315B2BCF8AF8009D71FC /* ProgramProgressOverlay.swift in Sources */,
E1E6C45129B104850064123F /* Button.swift in Sources */,
E19D41B52BF2C0130082B8B2 /* V2AnyData.swift in Sources */,
@ -4132,6 +4284,7 @@
E193D5432719407E00900D82 /* tvOSMainCoordinator.swift in Sources */,
E1DABAFA2A270E62008AC34A /* OverviewCard.swift in Sources */,
E11CEB8928998549003E74C7 /* BottomEdgeGradientModifier.swift in Sources */,
4E2AC4CC2C6C494E00DD600D /* VideoCodec.swift in Sources */,
E129428628F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */,
53ABFDE7267974EF00886593 /* ConnectToServerViewModel.swift in Sources */,
62E632E4267D3BA60063E547 /* MovieItemViewModel.swift in Sources */,
@ -4148,6 +4301,7 @@
E193D549271941CC00900D82 /* UserSignInView.swift in Sources */,
53ABFDE5267974EF00886593 /* ViewModel.swift in Sources */,
E148128628C15475003B8787 /* SortOrder+ItemSortOrder.swift in Sources */,
E1CB75722C80E71800217C76 /* DirectPlayProfile.swift in Sources */,
E1E1E24E28DF8A2E000DF5FD /* PreferenceKeys.swift in Sources */,
E1575E9B293E7B1E001665B1 /* EnvironmentValue+Keys.swift in Sources */,
E133328929538D8D00EE76AB /* Files.swift in Sources */,
@ -4161,6 +4315,7 @@
E1575E5D293E77B5001665B1 /* ItemViewType.swift in Sources */,
E12CC1AF28D0FAEA00678D5D /* NextUpLibraryViewModel.swift in Sources */,
E1575E7A293E77B5001665B1 /* TimeStampType.swift in Sources */,
E1CB758B2C80F9EC00217C76 /* CodecProfile.swift in Sources */,
E1763A252BF2F77B004DF6AB /* ScrollIfLargerThanContainerModifier.swift in Sources */,
E11E374E293E7F08009EF240 /* MediaSourceInfo.swift in Sources */,
E1E1643A28BAC2EF00323B0A /* SearchView.swift in Sources */,
@ -4180,7 +4335,6 @@
E1AEFA382BE36C4900CFAFD8 /* SwiftinStore+UserState.swift in Sources */,
E11895B42893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */,
E185920A28CEF23A00326F80 /* FocusGuide.swift in Sources */,
E1D37F532B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift in Sources */,
E1153D9C2BBA3E9D00424D36 /* LoadingCard.swift in Sources */,
53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */,
E102312F2BCF8A08009D71FC /* tvOSLiveTVCoordinator.swift in Sources */,
@ -4196,6 +4350,7 @@
DFB7C3E02C7AA43A00CE7CDC /* UserSignInState.swift in Sources */,
E1575E67293E77B5001665B1 /* OverlayType.swift in Sources */,
E1E9EFEA28C6B96500CC1F8B /* ServerButton.swift in Sources */,
4E9A24E82C82B6190023DA83 /* CustomProfileButton.swift in Sources */,
E1575E65293E77B5001665B1 /* VideoPlayerJumpLength.swift in Sources */,
E169C7B8296D2E8200AE25F9 /* SpecialFeaturesHStack.swift in Sources */,
E1153D962BBA3E2F00424D36 /* EpisodeHStack.swift in Sources */,
@ -4205,11 +4360,14 @@
E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */,
E113133B28BEB71D00930F75 /* FilterViewModel.swift in Sources */,
4E16FD582C01A32700110147 /* LetterPickerOrientation.swift in Sources */,
E19070502C8592B40004600E /* PlaybackCompatibility+Video.swift in Sources */,
E1575E70293E77B5001665B1 /* TextPair.swift in Sources */,
4E2AC4C62C6C492700DD600D /* MediaContainer.swift in Sources */,
E18E021C2887492B0022598C /* BlurView.swift in Sources */,
E187F7682B8E6A1C005400FE /* EnvironmentValue+Values.swift in Sources */,
E1E6C44729AECD5D0064123F /* PlayPreviousItemActionButton.swift in Sources */,
E1E6C44E29AEE9DC0064123F /* SmallMenuOverlay.swift in Sources */,
E1CB75832C80F66900217C76 /* VideoPlayerType+Swiftfin.swift in Sources */,
E10B1ECB2BD9AF8200A92EAF /* SwiftfinStore+V1.swift in Sources */,
E154966B296CA2EF00C4EF88 /* DownloadManager.swift in Sources */,
535870632669D21600D05A09 /* SwiftfinApp.swift in Sources */,
@ -4220,7 +4378,7 @@
E1575E9A293E7B1E001665B1 /* Array.swift in Sources */,
E1575E8D293E7B1E001665B1 /* URLComponents.swift in Sources */,
E187A60329AB28F0008387E6 /* RotateContentView.swift in Sources */,
E1D37F4F2B9CEDC400343D2B /* DeviceProfile.swift in Sources */,
4EBE064E2C7EB6D3004A6C03 /* VideoPlayerType.swift in Sources */,
E1575E94293E7B1E001665B1 /* VerticalAlignment.swift in Sources */,
E1575EA3293E7B1E001665B1 /* UIDevice.swift in Sources */,
E193D547271941C500900D82 /* SelectUserView.swift in Sources */,
@ -4233,6 +4391,7 @@
E18E021D2887492B0022598C /* AttributeStyleModifier.swift in Sources */,
E1CAF6632BA363840087D991 /* UIHostingController.swift in Sources */,
E10231492BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */,
E1CB75702C80E66700217C76 /* CommaStringBuilder.swift in Sources */,
5364F456266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */,
E1A42E5128CBE44500A14DCB /* LandscapePosterProgressBar.swift in Sources */,
E1575E7C293E77B5001665B1 /* TimerProxy.swift in Sources */,
@ -4249,6 +4408,7 @@
E1B90C8A2BC475E7007027C8 /* ScalingButtonStyle.swift in Sources */,
E1DABAFE2A27B982008AC34A /* RatingsCard.swift in Sources */,
E1C9261B288756BD002A7A66 /* DotHStack.swift in Sources */,
E1CB757D2C80F00D00217C76 /* TranscodingProfile.swift in Sources */,
E104C873296E0D0A00C1C3F9 /* IndicatorSettingsView.swift in Sources */,
E18ACA8D2A14773500BB4F35 /* (null) in Sources */,
E10B1E8E2BD7708900A92EAF /* QuickConnectView.swift in Sources */,
@ -4270,6 +4430,7 @@
E17AC96D2954E9CA003D2BC2 /* DownloadListView.swift in Sources */,
4E8B34EA2AB91B6E0018F305 /* ItemFilter.swift in Sources */,
E1A1528828FD229500600579 /* ChevronButton.swift in Sources */,
E1CB75732C80E71800217C76 /* DirectPlayProfile.swift in Sources */,
E1B490472967E2E500D3EDCE /* CoreStore.swift in Sources */,
6220D0C026D61C5000B8E046 /* ItemCoordinator.swift in Sources */,
E1B90C6A2BBE68D5007027C8 /* OffsetScrollView.swift in Sources */,
@ -4322,10 +4483,12 @@
E1E2F8422B757E0900B75998 /* OnFirstAppearModifier.swift in Sources */,
E1763A742BF3FA4C004DF6AB /* AppLoadingView.swift in Sources */,
E17AC9712954F636003D2BC2 /* DownloadListCoordinator.swift in Sources */,
4EBE06532C7ED0E1004A6C03 /* DeviceProfile.swift in Sources */,
E10EAA4F277BBCC4000269ED /* CGSize.swift in Sources */,
E150C0BA2BFD44F500944FFA /* ImagePipeline.swift in Sources */,
E18E01EB288747230022598C /* MovieItemContentView.swift in Sources */,
E17FB55B28C1266400311DFE /* GenresHStack.swift in Sources */,
4E2AC4BE2C6C48D200DD600D /* CustomDeviceProfileAction.swift in Sources */,
E18E01FA288747580022598C /* AboutAppView.swift in Sources */,
E170D103294CE8BF0017224C /* LoadingView.swift in Sources */,
6220D0AD26D5EABB00B8E046 /* ViewExtensions.swift in Sources */,
@ -4355,6 +4518,7 @@
E14E9DF12BCF7A99004E3371 /* ItemLetter.swift in Sources */,
E1B5861229E32EEF00E45D6E /* Sequence.swift in Sources */,
E11895B32893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */,
4E2AC4C82C6C493C00DD600D /* SubtitleFormat.swift in Sources */,
E19D41B02BF2B7540082B8B2 /* URLSessionConfiguration.swift in Sources */,
E172D3AD2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */,
4E762AAE2C3A1A95004D1579 /* PlaybackBitrate.swift in Sources */,
@ -4376,6 +4540,7 @@
E104C870296E087200C1C3F9 /* IndicatorSettingsView.swift in Sources */,
E12A9EF829499E0100731C3A /* JellyfinClient.swift in Sources */,
E1722DB129491C3900CC0239 /* ImageBlurHashes.swift in Sources */,
4EBE064F2C7ECE8D004A6C03 /* InlineEnumToggle.swift in Sources */,
E14EDEC82B8FB65F000F00A4 /* ItemFilterType.swift in Sources */,
E1EBCB42278BD174009FE6E9 /* TruncatedText.swift in Sources */,
62133890265F83A900A81A2A /* MediaView.swift in Sources */,
@ -4384,13 +4549,17 @@
E129429B28F4A5E300796AC6 /* PlaybackSettingsView.swift in Sources */,
E1E9017B28DAAE4D001B1594 /* RoundedCorner.swift in Sources */,
E18E01F2288747230022598C /* ActionButtonHStack.swift in Sources */,
4E2AC4C52C6C492700DD600D /* MediaContainer.swift in Sources */,
4E2AC4CB2C6C494E00DD600D /* VideoCodec.swift in Sources */,
E1EA09692BED78BB004CDE76 /* UserAccessPolicy.swift in Sources */,
E18E0204288749200022598C /* RowDivider.swift in Sources */,
E18E01DA288747230022598C /* iPadOSEpisodeContentView.swift in Sources */,
E1CB75752C80EAFA00217C76 /* ArrayBuilder.swift in Sources */,
E1047E2327E5880000CB0D4A /* SystemImageContentView.swift in Sources */,
E1C8CE5B28FE512400DF5D7B /* CGPoint.swift in Sources */,
E18ACA922A15A32F00BB4F35 /* (null) in Sources */,
E1A3E4C92BB74EA3005C59F8 /* LoadingCard.swift in Sources */,
4E71D6892C80910900A0174D /* EditCustomDeviceProfileView.swift in Sources */,
E1E1E24D28DF8A2E000DF5FD /* PreferenceKeys.swift in Sources */,
E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */,
E15756322935642A00976E1F /* Double.swift in Sources */,
@ -4422,7 +4591,6 @@
E1194F4E2BEABA9100888DB6 /* NavigationBarCloseButton.swift in Sources */,
E113133428BE988200930F75 /* NavigationBarFilterDrawer.swift in Sources */,
5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */,
E1D37F552B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift in Sources */,
E129428528F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */,
E11E0E8C2BF7E76F007676DD /* DataCache.swift in Sources */,
E10231482BCF8A6D009D71FC /* ChannelLibraryViewModel.swift in Sources */,
@ -4448,6 +4616,7 @@
E1401CB129386C9200E8B599 /* UIColor.swift in Sources */,
E1E2F8452B757E3400B75998 /* SinceLastDisappearModifier.swift in Sources */,
E18E01AB288746AF0022598C /* PillHStack.swift in Sources */,
E19070492C84F2BB0004600E /* ButtonStyle-iOS.swift in Sources */,
E1401CAB2938140A00E8B599 /* LightAppIcon.swift in Sources */,
E19D41A72BEEDC450082B8B2 /* UserLocalSecurityViewModel.swift in Sources */,
E18E01E4288747230022598C /* CompactLogoScrollView.swift in Sources */,
@ -4460,6 +4629,7 @@
E102313B2BCF8A3C009D71FC /* ProgramProgressOverlay.swift in Sources */,
E1937A61288F32DB00CB80AA /* Poster.swift in Sources */,
E145EB482BE0C136003BF6F3 /* ScrollIfLargerThanContainerModifier.swift in Sources */,
E1CB757C2C80F00D00217C76 /* TranscodingProfile.swift in Sources */,
E1CAF65F2BA345830087D991 /* MediaViewModel.swift in Sources */,
E1EA9F6A28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */,
E133328D2953AE4B00EE76AB /* CircularProgressView.swift in Sources */,
@ -4473,6 +4643,7 @@
E13DD3F5271793BB009D4DAF /* UserSignInView.swift in Sources */,
E1D3044428D1991900587289 /* LibraryViewTypeToggle.swift in Sources */,
C45C36542A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */,
E1CB75822C80F66900217C76 /* VideoPlayerType+Swiftfin.swift in Sources */,
E148128B28C15526003B8787 /* ItemSortBy.swift in Sources */,
E10231412BCF8A3C009D71FC /* ChannelLibraryView.swift in Sources */,
E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */,
@ -4491,6 +4662,7 @@
E1401CA5293813F400E8B599 /* InvertedDarkAppIcon.swift in Sources */,
E1C8CE7C28FF015000DF5D7B /* TrailingTimestampType.swift in Sources */,
C46DD8E22A8DC7FB0046A504 /* LiveMainOverlay.swift in Sources */,
4EC1C86D2C80903A00E2879E /* CustomProfileButton.swift in Sources */,
E1FE69A728C29B720021BC93 /* ProgressBar.swift in Sources */,
E13332912953B91000EE76AB /* DownloadTaskCoordinator.swift in Sources */,
E43918662AD5C8310045A18C /* OnScenePhaseChangedModifier.swift in Sources */,
@ -4512,7 +4684,6 @@
E1DE84142B9531C1008CCE21 /* OrderedSectionSelectorView.swift in Sources */,
E13DD3FC2717EAE8009D4DAF /* SelectUserView.swift in Sources */,
E18E01DE288747230022598C /* iPadOSSeriesItemView.swift in Sources */,
E1D37F4E2B9CEDC400343D2B /* DeviceProfile.swift in Sources */,
E1EF4C412911B783008CC695 /* StreamType.swift in Sources */,
6220D0CC26D640C400B8E046 /* AppURLHandler.swift in Sources */,
E1A3E4CF2BB7E02B005C59F8 /* DelayedProgressView.swift in Sources */,
@ -4532,10 +4703,13 @@
6220D0B126D5EC9900B8E046 /* SettingsCoordinator.swift in Sources */,
E10B1ECA2BD9AF8200A92EAF /* SwiftfinStore+V1.swift in Sources */,
E1AA331D2782541500F6439C /* PrimaryButton.swift in Sources */,
4E2AC4D92C6C4D9400DD600D /* PlaybackQualitySettingsView.swift in Sources */,
E18E01E3288747230022598C /* CompactPortraitScrollView.swift in Sources */,
62C29EA626D1036A00C1D2E7 /* HomeCoordinator.swift in Sources */,
531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */,
E18A8E8328D60BC400333B9A /* VideoPlayer.swift in Sources */,
4EBE064D2C7EB6D3004A6C03 /* VideoPlayerType.swift in Sources */,
E1366A222C826DA700A36DED /* EditCustomDeviceProfileCoordinator.swift in Sources */,
E1CCF12E28ABF989006CAC9E /* PosterDisplayType.swift in Sources */,
E10B1EC12BD9AD6100A92EAF /* V1UserModel.swift in Sources */,
E1E7506A2A33E9B400B2C1EE /* RatingsCard.swift in Sources */,
@ -4552,7 +4726,6 @@
62E632E0267D30CA0063E547 /* ItemLibraryViewModel.swift in Sources */,
E1B33EB028EA890D0073B0FD /* Equatable.swift in Sources */,
E1549662296CA2EF00C4EF88 /* UserSession.swift in Sources */,
E15756362936856700976E1F /* VideoPlayerType.swift in Sources */,
E1DA654C28E69B0500592A73 /* SpecialFeatureType.swift in Sources */,
E11CEB8B28998552003E74C7 /* View-iOS.swift in Sources */,
E10B1ECD2BD9AFD800A92EAF /* SwiftfinStore+V2.swift in Sources */,
@ -4572,11 +4745,11 @@
E18E01DD288747230022598C /* iPadOSSeriesItemContentView.swift in Sources */,
E14EA1692BF7330A00DE757A /* UserProfileImageViewModel.swift in Sources */,
E18ACA952A15A3E100BB4F35 /* (null) in Sources */,
4E73E2AE2C420207002D2A78 /* MaximumBitrateSettingsView.swift in Sources */,
E1D5C39B28DF993400CDBEFB /* ThumbSlider.swift in Sources */,
E1DC983D296DEB9B00982F06 /* UnwatchedIndicator.swift in Sources */,
E1FE69AA28C29CC20021BC93 /* LandscapePosterProgressBar.swift in Sources */,
E1C925F72887504B002A7A66 /* PanDirectionGestureRecognizer.swift in Sources */,
E1CB758C2C80F9EC00217C76 /* CodecProfile.swift in Sources */,
E18E01E9288747230022598C /* SeriesItemView.swift in Sources */,
E15756342936851D00976E1F /* NativeVideoPlayerSettingsView.swift in Sources */,
E1D4BF7C2719D05000A11E64 /* AppSettingsView.swift in Sources */,
@ -4592,19 +4765,22 @@
E17FB55728C1256400311DFE /* CastAndCrewHStack.swift in Sources */,
62E632E3267D3BA60063E547 /* MovieItemViewModel.swift in Sources */,
E150C0BD2BFD45BD00944FFA /* RedrawOnNotificationView.swift in Sources */,
E190704D2C858CEB0004600E /* VideoPlayerType+Shared.swift in Sources */,
E113133828BEADBA00930F75 /* LibraryParent.swift in Sources */,
E17DC74D2BE7601E00B42379 /* SettingsBarButton.swift in Sources */,
E190704F2C8592B40004600E /* PlaybackCompatibility+Video.swift in Sources */,
E104DC962B9E7E29008F506D /* AssertionFailureView.swift in Sources */,
E102312C2BCF8A08009D71FC /* iOSLiveTVCoordinator.swift in Sources */,
E149CCAD2BE6ECC8008B9331 /* Storable.swift in Sources */,
E1CB75792C80ECF100217C76 /* VideoPlayerType+Native.swift in Sources */,
E18ACA8F2A15A2CF00BB4F35 /* (null) in Sources */,
E1401CA72938140300E8B599 /* PrimaryAppIcon.swift in Sources */,
E1937A3E288F0D3D00CB80AA /* UIScreen.swift in Sources */,
E10B1EBE2BD9AD5C00A92EAF /* V1ServerModel.swift in Sources */,
E1EBCB46278BD595009FE6E9 /* ItemOverviewView.swift in Sources */,
E10B1EB62BD98C6600A92EAF /* AddUserRow.swift in Sources */,
E1CB75802C80F28F00217C76 /* SubtitleProfile.swift in Sources */,
E1DD20412BE1EB8C00C0DE51 /* AddUserButton.swift in Sources */,
E1D37F582B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift in Sources */,
E145EB422BE0A6EE003BF6F3 /* ServerSelectionMenu.swift in Sources */,
4E5E48E52AB59806003F1B48 /* CustomizeViewsSettings.swift in Sources */,
E14EA15E2BF6F72900DE757A /* PhotoPicker.swift in Sources */,
@ -4620,8 +4796,10 @@
E1545BD82BDC55C300D9578F /* ResetUserPasswordView.swift in Sources */,
E1E750682A33E9B400B2C1EE /* OverviewCard.swift in Sources */,
E1CCF13128AC07EC006CAC9E /* PosterHStack.swift in Sources */,
4E2AC4C22C6C491200DD600D /* AudoCodec.swift in Sources */,
E10B1EC72BD9AF6100A92EAF /* V2ServerModel.swift in Sources */,
E13DD3C827164B1E009D4DAF /* UIDevice.swift in Sources */,
4EBE06462C7E9509004A6C03 /* PlaybackCompatibility.swift in Sources */,
C46DD8DD2A8DC3420046A504 /* LiveNativeVideoPlayer.swift in Sources */,
E1AD104D26D96CE3003E4A08 /* BaseItemDto.swift in Sources */,
E13DD3BF27163DD7009D4DAF /* AppDelegate.swift in Sources */,
@ -4633,11 +4811,13 @@
E18E01F1288747230022598C /* PlayButton.swift in Sources */,
E129429028F0BDC300796AC6 /* TimeStampType.swift in Sources */,
E1B490442967E26300D3EDCE /* PersistentLogHandler.swift in Sources */,
E1CB756F2C80E66700217C76 /* CommaStringBuilder.swift in Sources */,
E19D41AC2BF288110082B8B2 /* ServerCheckView.swift in Sources */,
E1D5C39928DF914700CDBEFB /* CapsuleSlider.swift in Sources */,
62E1DCC3273CE19800C9AE76 /* URL.swift in Sources */,
E11BDF7A2B85529D0045C54A /* SupportedCaseIterable.swift in Sources */,
E170D0E4294CC8AB0017224C /* VideoPlayer+KeyCommands.swift in Sources */,
4EC1C8692C808FBB00E2879E /* CustomDeviceProfileSettingsView.swift in Sources */,
E18E01E6288747230022598C /* CollectionItemView.swift in Sources */,
E15D4F0A2B1BD88900442DB8 /* Edge.swift in Sources */,
6220D0BA26D6092100B8E046 /* FilterCoordinator.swift in Sources */,
@ -4676,6 +4856,7 @@
E1D3043528D1763100587289 /* SeeAllButton.swift in Sources */,
4E73E2A62C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift in Sources */,
E172D3B22BACA569007B4647 /* EpisodeContent.swift in Sources */,
4EC1C8522C7FDFA300E2879E /* PlaybackDeviceProfile.swift in Sources */,
DFB7C3DF2C7AA43A00CE7CDC /* UserSignInState.swift in Sources */,
E13F05EC28BC9000003499D2 /* LibraryDisplayType.swift in Sources */,
4E16FD572C01A32700110147 /* LetterPickerOrientation.swift in Sources */,
@ -4686,7 +4867,6 @@
E145EB4D2BE1688E003BF6F3 /* SwiftinStore+UserState.swift in Sources */,
53EE24E6265060780068F029 /* SearchView.swift in Sources */,
E164A8152BE58C2F00A54B18 /* V2AnyData.swift in Sources */,
E1D37F522B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift in Sources */,
E1DC9841296DEBD800982F06 /* WatchedIndicator.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

View File

@ -1,5 +1,5 @@
{
"originHash" : "54fc43873cff9b3db2ad273a82066d201e4ea59316a81526b530004e4d98b974",
"originHash" : "323b2ad9aaa9c000faf264d68272f0e9fab1349d9f910a0b95ee6aea10460f31",
"pins" : [
{
"identity" : "blurhashkit",

View File

@ -28,6 +28,9 @@ struct OrderedSectionSelectorView<Element: Displayable & Hashable>: View {
}
private func select(element: Element) {
UIDevice.impact(.light)
if selection.value.contains(element) {
selection.value.removeAll(where: { $0 == element })
} else {

View File

@ -0,0 +1,53 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Defaults
import SwiftUI
extension ButtonStyle where Self == ToolbarPillButtonStyle {
static var toolbarPill: ToolbarPillButtonStyle {
ToolbarPillButtonStyle()
}
}
struct ToolbarPillButtonStyle: ButtonStyle {
@Default(.accentColor)
private var accentColor
@Environment(\.isEnabled)
private var isEnabled
private var foregroundStyle: some ShapeStyle {
if isEnabled {
accentColor.overlayColor
} else {
Color.secondary.overlayColor
}
}
private var background: some ShapeStyle {
if isEnabled {
accentColor
} else {
Color.secondary
}
}
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundStyle(foregroundStyle)
.font(.headline)
.padding(.vertical, 5)
.padding(.horizontal, 10)
.background(background)
.clipShape(RoundedRectangle(cornerRadius: 10))
.opacity(isEnabled && !configuration.isPressed ? 1 : 0.5)
}
}

View File

@ -8,6 +8,10 @@
import SwiftUI
// TODO: see if could be moved to `Shared`
// MARK: EpisodeSelectorLabelStyle
extension LabelStyle where Self == EpisodeSelectorLabelStyle {
static var episodeSelector: EpisodeSelectorLabelStyle {
@ -35,3 +39,28 @@ struct EpisodeSelectorLabelStyle: LabelStyle {
.font(.caption)
}
}
// MARK: SectionFooterWithImageLabelStyle
extension LabelStyle where Self == SectionFooterWithImageLabelStyle<AnyShapeStyle> {
static func sectionFooterWithImage<ImageStyle: ShapeStyle>(imageStyle: ImageStyle) -> SectionFooterWithImageLabelStyle<ImageStyle> {
SectionFooterWithImageLabelStyle(imageStyle: imageStyle)
}
}
struct SectionFooterWithImageLabelStyle<ImageStyle: ShapeStyle>: LabelStyle {
let imageStyle: ImageStyle
func makeBody(configuration: Configuration) -> some View {
HStack {
configuration.icon
.foregroundStyle(imageStyle)
.backport
.fontWeight(.bold)
configuration.title
}
}
}

View File

@ -0,0 +1,67 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Foundation
import SwiftUI
extension CustomDeviceProfileSettingsView {
struct CustomProfileButton: View {
let profile: CustomDeviceProfile
let onSelect: () -> Void
@ViewBuilder
private func profileDetailsView(title: String, detail: String) -> some View {
VStack(alignment: .leading) {
Text(title)
.fontWeight(.semibold)
.foregroundStyle(.primary)
Text(detail)
.foregroundColor(.secondary)
}
.font(.subheadline)
}
var body: some View {
Button(action: onSelect) {
HStack {
VStack(alignment: .leading, spacing: 8) {
profileDetailsView(
title: L10n.audio,
detail: profile.audio.map(\.displayTitle).joined(separator: ", ")
)
profileDetailsView(
title: L10n.video,
detail: profile.video.map(\.displayTitle).joined(separator: ", ")
)
profileDetailsView(
title: L10n.containers,
detail: profile.container.map(\.displayTitle).joined(separator: ", ")
)
profileDetailsView(
title: L10n.useAsTranscodingProfile,
detail: profile.useAsTranscodingProfile ? "Yes" : "No"
)
}
Spacer()
Image(systemName: "chevron.right")
.font(.body.weight(.regular))
.foregroundColor(.secondary)
}
}
.foregroundStyle(.primary)
}
}
}

View File

@ -0,0 +1,146 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Defaults
import SwiftUI
extension CustomDeviceProfileSettingsView {
struct EditCustomDeviceProfileView: View {
@Default(.accentColor)
private var accentColor
@StoredValue(.User.customDeviceProfiles)
private var customDeviceProfiles
@EnvironmentObject
private var router: EditCustomDeviceProfileCoordinator.Router
@State
private var isPresentingNotSaved = false
@State
private var profile: CustomDeviceProfile
private let createProfile: Bool
private let source: Binding<CustomDeviceProfile>?
private var isValid: Bool {
profile.audio.isNotEmpty &&
profile.video.isNotEmpty &&
profile.container.isNotEmpty
}
init(profile: Binding<CustomDeviceProfile>?) {
createProfile = profile == nil
if let profile {
self._profile = State(initialValue: profile.wrappedValue)
self.source = profile
} else {
self._profile = State(initialValue: .init(type: .video))
self.source = nil
}
}
@ViewBuilder
private func codecSection(
title: String,
content: String,
onSelect: @escaping () -> Void
) -> some View {
Button(action: onSelect) {
HStack {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.fontWeight(.semibold)
.foregroundStyle(.primary)
if content.isEmpty {
Label(L10n.none, systemImage: "exclamationmark.circle.fill")
.labelStyle(.sectionFooterWithImage(imageStyle: .orange))
.foregroundColor(.secondary)
} else {
Text(content)
.foregroundColor(.secondary)
}
}
.font(.subheadline)
Spacer()
Image(systemName: "chevron.right")
.font(.body.weight(.regular))
.foregroundColor(.secondary)
}
}
.foregroundStyle(.primary)
}
var body: some View {
Form {
Toggle(L10n.useAsTranscodingProfile, isOn: $profile.useAsTranscodingProfile)
Section {
codecSection(
title: L10n.audio,
content: profile.audio.map(\.displayTitle).joined(separator: ", ")
) {
router.route(to: \.customDeviceAudioEditor, $profile.audio)
}
codecSection(
title: L10n.video,
content: profile.video.map(\.displayTitle).joined(separator: ", ")
) {
router.route(to: \.customDeviceVideoEditor, $profile.video)
}
codecSection(
title: L10n.containers,
content: profile.container.map(\.displayTitle).joined(separator: ", ")
) {
router.route(to: \.customDeviceContainerEditor, $profile.container)
}
} footer: {
if !isValid {
Label("Missing codec values", systemImage: "exclamationmark.circle.fill")
.labelStyle(.sectionFooterWithImage(imageStyle: .orange))
}
}
}
.interactiveDismissDisabled(true)
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden()
.navigationBarCloseButton {
isPresentingNotSaved = true
}
.navigationTitle(L10n.customProfile)
.topBarTrailing {
Button("Save") {
if createProfile {
customDeviceProfiles.append(profile)
} else {
source?.wrappedValue = profile
}
UIDevice.impact(.light)
router.dismissCoordinator()
}
.buttonStyle(.toolbarPill)
.disabled(!isValid)
}
.alert("Profile not saved", isPresented: $isPresentingNotSaved) {
Button("Close", role: .destructive) {
router.dismissCoordinator()
}
}
}
}
}

View File

@ -0,0 +1,83 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Defaults
import Factory
import SwiftUI
struct CustomDeviceProfileSettingsView: View {
@Default(.VideoPlayer.Playback.customDeviceProfileAction)
private var customDeviceProfileAction
@StoredValue(.User.customDeviceProfiles)
private var customProfiles: [CustomDeviceProfile]
@EnvironmentObject
private var router: SettingsCoordinator.Router
private var isValid: Bool {
customDeviceProfileAction == .add ||
customProfiles.isNotEmpty
}
private func removeProfile(at offsets: IndexSet) {
customProfiles.remove(atOffsets: offsets)
}
var body: some View {
List {
Section {
CaseIterablePicker(
L10n.behavior,
selection: $customDeviceProfileAction
)
} footer: {
VStack(spacing: 8) {
switch customDeviceProfileAction {
case .add:
L10n.customDeviceProfileAdd.text
case .replace:
L10n.customDeviceProfileReplace.text
}
if !isValid {
Label("No profiles defined. Playback issues may occur.", systemImage: "exclamationmark.circle.fill")
.labelStyle(.sectionFooterWithImage(imageStyle: .orange))
}
}
}
Section(L10n.profiles) {
if customProfiles.isEmpty {
Button("Add profile") {
router.route(to: \.createCustomDeviceProfile)
}
}
ForEach($customProfiles, id: \.self) { $profile in
CustomProfileButton(profile: profile) {
router.route(to: \.editCustomDeviceProfile, $profile)
}
}
.onDelete(perform: removeProfile)
}
}
.navigationTitle(L10n.profiles)
.topBarTrailing {
if customProfiles.isNotEmpty {
Button("Add") {
UIDevice.impact(.light)
router.route(to: \.createCustomDeviceProfile)
}
.buttonStyle(.toolbarPill)
}
}
}
}

View File

@ -9,31 +9,13 @@
import Defaults
import SwiftUI
// Note: Used for experimental settings that may be removed or implemented
// officially. Keep for future settings.
struct ExperimentalSettingsView: View {
@Default(.Experimental.forceDirectPlay)
private var forceDirectPlay
@Default(.Experimental.liveTVForceDirectPlay)
private var liveTVForceDirectPlay
var body: some View {
Form {
Section {
Toggle("Force Direct Play", isOn: $forceDirectPlay)
} header: {
Text("Video Player")
}
Section {
Toggle("Live TV Force Direct Play", isOn: $liveTVForceDirectPlay)
} header: {
Text("Live TV")
}
}
.navigationTitle(L10n.experimental)
Form {}
.navigationTitle(L10n.experimental)
}
}

View File

@ -1,41 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Defaults
import SwiftUI
struct MaximumBitrateSettingsView: View {
@Default(.VideoPlayer.appMaximumBitrate)
private var appMaximumBitrate
@Default(.VideoPlayer.appMaximumBitrateTest)
private var appMaximumBitrateTest
var body: some View {
Form {
Section {
CaseIterablePicker(
L10n.maximumBitrate,
selection: $appMaximumBitrate
)
if appMaximumBitrate == PlaybackBitrate.auto {
CaseIterablePicker(
L10n.testSize,
selection: $appMaximumBitrateTest
)
}
} footer: {
if appMaximumBitrate == PlaybackBitrate.auto {
Text(L10n.bitrateTestDescription)
}
}
}
.navigationTitle(L10n.maximumBitrate)
}
}

View File

@ -0,0 +1,80 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2024 Jellyfin & Jellyfin Contributors
//
import Defaults
import SwiftUI
struct PlaybackQualitySettingsView: View {
@Default(.VideoPlayer.Playback.appMaximumBitrate)
private var appMaximumBitrate
@Default(.VideoPlayer.Playback.appMaximumBitrateTest)
private var appMaximumBitrateTest
@Default(.VideoPlayer.Playback.compatibilityMode)
private var compatibilityMode
@EnvironmentObject
private var router: SettingsCoordinator.Router
var body: some View {
Form {
Section {
CaseIterablePicker(
L10n.maximumBitrate,
selection: $appMaximumBitrate
)
} header: {
L10n.bitrateDefault.text
} footer: {
L10n.bitrateDefaultDescription.text
}
.animation(.none, value: appMaximumBitrate)
if appMaximumBitrate == .auto {
Section {
CaseIterablePicker(
L10n.testSize,
selection: $appMaximumBitrateTest
)
} header: {
L10n.bitrateTest.text
} footer: {
VStack(alignment: .leading, spacing: 8) {
L10n.bitrateTestDescription.text
L10n.bitrateTestDisclaimer.text
}
}
}
// TODO: Have a small description and a "Learn More..."
// button that will open a page for longer descriptions
// of each option. See: iOS Settings/Accessibility/VoiceOver
// for reference
Section {
CaseIterablePicker(
L10n.compatibility,
selection: $compatibilityMode
)
.animation(.none, value: compatibilityMode)
if compatibilityMode == .custom {
ChevronButton(L10n.profiles)
.onSelect {
router.route(to: \.customDeviceProfileSettings)
}
}
} header: {
L10n.deviceProfile.text
}
}
.animation(.linear, value: appMaximumBitrate)
.animation(.linear, value: compatibilityMode)
.navigationTitle(L10n.playbackQuality)
}
}

View File

@ -72,9 +72,9 @@ struct SettingsView: View {
router.route(to: \.videoPlayerSettings)
}
ChevronButton(L10n.maximumBitrate)
ChevronButton(L10n.playbackQuality)
.onSelect {
router.route(to: \.maximumBitrateSettings)
router.route(to: \.playbackQualitySettings)
}
}
@ -86,10 +86,13 @@ struct SettingsView: View {
router.route(to: \.customizeViewsSettings)
}
ChevronButton(L10n.experimental)
.onSelect {
router.route(to: \.experimentalSettings)
}
// Note: uncomment if there are current
// experimental settings
// ChevronButton(L10n.experimental)
// .onSelect {
// router.route(to: \.experimentalSettings)
// }
}
Section {

View File

@ -74,12 +74,8 @@ struct ResetUserPasswordView: View {
Text("Confirm New Password")
} footer: {
if newPassword != confirmNewPassword {
HStack {
Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(.orange)
Text("New passwords do not match")
}
Label("New passwords to not match", systemImage: "exclamationmark.circle.fill")
.labelStyle(.sectionFooterWithImage(imageStyle: .orange))
}
}

View File

@ -208,23 +208,11 @@ struct UserSignInView: View {
} footer: {
switch accessPolicy {
case .requireDeviceAuthentication:
HStack {
Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(.orange)
.backport
.fontWeight(.bold)
Text("This user will require device authentication.")
}
Label("This user will require device authentication.", systemImage: "exclamationmark.circle.fill")
.labelStyle(.sectionFooterWithImage(imageStyle: .orange))
case .requirePin:
HStack {
Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(.orange)
.backport
.fontWeight(.bold)
Text("This user will require a pin.")
}
Label("This user will require a pin.", systemImage: "exclamationmark.circle.fill")
.labelStyle(.sectionFooterWithImage(imageStyle: .orange))
case .none:
EmptyView()
}