From f5bd1b8fcd249c4095379e788ead31316733aea5 Mon Sep 17 00:00:00 2001 From: Joe Date: Mon, 2 Sep 2024 15:33:02 -0600 Subject: [PATCH] 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 Co-authored-by: Ethan Pippin --- .../CustomDeviceProfileCoordinator.swift | 45 +++ .../EditCustomDeviceProfileCoordinator.swift | 57 ++++ .../PlaybackQualitySettingsCoordinator.swift | 41 +++ Shared/Coordinators/SettingsCoordinator.swift | 75 +++-- Shared/Extensions/Array.swift | 5 + .../BaseItemDto+VideoPlayerViewModel.swift | 26 +- .../Extensions/JellyfinAPI/CodecProfile.swift | 32 +++ .../JellyfinAPI/DeviceProfile.swift | 79 ++++++ .../DeviceProfile+NativeProfile.swift | 90 ------ .../DeviceProfile+SharedCodecProfiles.swift | 78 ------ .../DeviceProfile+SwiftfinProfile.swift | 98 ------- .../DeviceProfile/DeviceProfile.swift | 38 --- .../JellyfinAPI/DirectPlayProfile.swift | 45 +++ ...aSourceInfo+ItemVideoPlayerViewModel.swift | 7 +- .../JellyfinAPI}/SpecialFeatureType.swift | 0 .../JellyfinAPI/SubtitleProfile.swift | 40 +++ .../JellyfinAPI/TranscodingProfile.swift | 55 ++++ Shared/Objects/ArrayBuilder.swift | 41 +++ Shared/Objects/CommaStringBuilder.swift | 27 ++ ....swift => CustomDeviceProfileAction.swift} | 14 +- .../Objects/MediaComponents/AudoCodec.swift | 114 ++++++++ .../MediaComponents/MediaContainer.swift | 51 ++++ .../MediaComponents/SubtitleFormat.swift | 82 ++++++ .../Objects/MediaComponents/VideoCodec.swift | 90 ++++++ .../PlaybackCompatibility+Video.swift | 54 ++++ .../PlaybackCompatibility.swift | 31 ++ Shared/Objects/PlaybackDeviceProfile.swift | 73 +++++ Shared/Objects/StreamType.swift | 2 +- .../VideoPlayerType+Native.swift | 143 ++++++++++ .../VideoPlayerType+Shared.swift | 97 +++++++ .../VideoPlayerType+Swiftfin.swift | 140 ++++++++++ .../VideoPlayerType/VideoPlayerType.swift | 54 ++++ Shared/Services/SwiftfinDefaults.swift | 9 +- Shared/Strings/Strings.swift | 40 ++- .../StoredValue/StoredValues+User.swift | 8 + .../OrderedSectionSelectorView.swift | 199 +++++++++++++ .../Components/CustomProfileButton.swift | 65 +++++ .../EditCustomDeviceProfileView.swift | 145 ++++++++++ .../CustomDeviceProfileSettingsView.swift | 105 +++++++ .../ExperimentalSettingsView.swift | 21 +- .../MaximumBitrateSettingsView.swift | 45 --- .../PlaybackQualitySettingsView.swift | 81 ++++++ .../Views/SettingsView/SettingsView.swift | 14 +- Swiftfin.xcodeproj/project.pbxproj | 264 +++++++++++++++--- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../OrderedSectionSelectorView.swift | 3 + Swiftfin/Extensions/ButtonStyle-iOS.swift | 53 ++++ Swiftfin/Extensions/Label-iOS.swift | 29 ++ .../Components/CustomProfileButton.swift | 67 +++++ .../EditCustomDeviceProfileView.swift | 146 ++++++++++ .../CustomDeviceProfileSettingsView.swift | 83 ++++++ .../ExperimentalSettingsView.swift | 28 +- .../MaximumBitrateSettingsView.swift | 41 --- .../PlaybackQualitySettingsView.swift | 80 ++++++ .../SettingsView/SettingsView.swift | 15 +- .../ResetUserPasswordView.swift | 8 +- .../Views/UserSignInView/UserSignInView.swift | 20 +- Translations/en.lproj/Localizable.strings | Bin 27976 -> 31726 bytes 58 files changed, 2844 insertions(+), 551 deletions(-) create mode 100644 Shared/Coordinators/CustomDeviceProfileCoordinator.swift create mode 100644 Shared/Coordinators/EditCustomDeviceProfileCoordinator.swift create mode 100644 Shared/Coordinators/PlaybackQualitySettingsCoordinator.swift create mode 100644 Shared/Extensions/JellyfinAPI/CodecProfile.swift create mode 100644 Shared/Extensions/JellyfinAPI/DeviceProfile.swift delete mode 100644 Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile+NativeProfile.swift delete mode 100644 Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile+SharedCodecProfiles.swift delete mode 100644 Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile+SwiftfinProfile.swift delete mode 100644 Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile.swift create mode 100644 Shared/Extensions/JellyfinAPI/DirectPlayProfile.swift rename Shared/{Objects => Extensions/JellyfinAPI}/SpecialFeatureType.swift (100%) create mode 100644 Shared/Extensions/JellyfinAPI/SubtitleProfile.swift create mode 100644 Shared/Extensions/JellyfinAPI/TranscodingProfile.swift create mode 100644 Shared/Objects/ArrayBuilder.swift create mode 100644 Shared/Objects/CommaStringBuilder.swift rename Shared/Objects/{VideoPlayerType.swift => CustomDeviceProfileAction.swift} (62%) create mode 100644 Shared/Objects/MediaComponents/AudoCodec.swift create mode 100644 Shared/Objects/MediaComponents/MediaContainer.swift create mode 100644 Shared/Objects/MediaComponents/SubtitleFormat.swift create mode 100644 Shared/Objects/MediaComponents/VideoCodec.swift create mode 100644 Shared/Objects/PlaybackCompatibility/PlaybackCompatibility+Video.swift create mode 100644 Shared/Objects/PlaybackCompatibility/PlaybackCompatibility.swift create mode 100644 Shared/Objects/PlaybackDeviceProfile.swift create mode 100644 Shared/Objects/VideoPlayerType/VideoPlayerType+Native.swift create mode 100644 Shared/Objects/VideoPlayerType/VideoPlayerType+Shared.swift create mode 100644 Shared/Objects/VideoPlayerType/VideoPlayerType+Swiftfin.swift create mode 100644 Shared/Objects/VideoPlayerType/VideoPlayerType.swift create mode 100644 Swiftfin tvOS/Components/OrderedSectionSelectorView.swift create mode 100644 Swiftfin tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/CustomProfileButton.swift create mode 100644 Swiftfin tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/EditCustomDeviceProfileView.swift create mode 100644 Swiftfin tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/CustomDeviceProfileSettingsView.swift delete mode 100644 Swiftfin tvOS/Views/SettingsView/MaximumBitrateSettingsView.swift create mode 100644 Swiftfin tvOS/Views/SettingsView/PlaybackQualitySettingsView.swift create mode 100644 Swiftfin/Extensions/ButtonStyle-iOS.swift create mode 100644 Swiftfin/Views/SettingsView/CustomDeviceProfileSettingsView/Components/CustomProfileButton.swift create mode 100644 Swiftfin/Views/SettingsView/CustomDeviceProfileSettingsView/Components/EditCustomDeviceProfileView.swift create mode 100644 Swiftfin/Views/SettingsView/CustomDeviceProfileSettingsView/CustomDeviceProfileSettingsView.swift delete mode 100644 Swiftfin/Views/SettingsView/MaximumBitrateSettingsView.swift create mode 100644 Swiftfin/Views/SettingsView/PlaybackQualitySettingsView.swift diff --git a/Shared/Coordinators/CustomDeviceProfileCoordinator.swift b/Shared/Coordinators/CustomDeviceProfileCoordinator.swift new file mode 100644 index 00000000..7af5a547 --- /dev/null +++ b/Shared/Coordinators/CustomDeviceProfileCoordinator.swift @@ -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 { + NavigationViewCoordinator( + PlaybackQualitySettingsCoordinator() + ) + } + + func makeEditCustomDeviceProfile(profile: Binding) + -> NavigationViewCoordinator { + NavigationViewCoordinator(EditCustomDeviceProfileCoordinator(profile: profile)) + } + + func makeCreateCustomDeviceProfile() -> NavigationViewCoordinator { + NavigationViewCoordinator(EditCustomDeviceProfileCoordinator()) + } + + @ViewBuilder + func makeStart() -> some View { + CustomDeviceProfileSettingsView() + } +} diff --git a/Shared/Coordinators/EditCustomDeviceProfileCoordinator.swift b/Shared/Coordinators/EditCustomDeviceProfileCoordinator.swift new file mode 100644 index 00000000..f84149b5 --- /dev/null +++ b/Shared/Coordinators/EditCustomDeviceProfileCoordinator.swift @@ -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? + + init(profile: Binding? = 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) + } +} diff --git a/Shared/Coordinators/PlaybackQualitySettingsCoordinator.swift b/Shared/Coordinators/PlaybackQualitySettingsCoordinator.swift new file mode 100644 index 00000000..88d388b6 --- /dev/null +++ b/Shared/Coordinators/PlaybackQualitySettingsCoordinator.swift @@ -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 { + NavigationViewCoordinator( + CustomDeviceProfileCoordinator() + ) + } + + func makeEditCustomDeviceProfile(profile: Binding) + -> NavigationViewCoordinator { + NavigationViewCoordinator(EditCustomDeviceProfileCoordinator(profile: profile)) + } + + func makeCreateCustomDeviceProfile() -> NavigationViewCoordinator { + NavigationViewCoordinator(EditCustomDeviceProfileCoordinator()) + } + + @ViewBuilder + func makeStart() -> some View { + PlaybackQualitySettingsView() + } +} diff --git a/Shared/Coordinators/SettingsCoordinator.swift b/Shared/Coordinators/SettingsCoordinator.swift index 431d8893..f4f1d2d5 100644 --- a/Shared/Coordinators/SettingsCoordinator.swift +++ b/Shared/Coordinators/SettingsCoordinator.swift @@ -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) + -> NavigationViewCoordinator { + NavigationViewCoordinator(EditCustomDeviceProfileCoordinator(profile: profile)) + } + + func makeCreateCustomDeviceProfile() -> NavigationViewCoordinator { + 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 { - NavigationViewCoordinator(CustomizeSettingsCoordinator()) + func makeCustomizeViewsSettings() -> NavigationViewCoordinator { + NavigationViewCoordinator( + BasicNavigationViewCoordinator { + CustomizeViewsSettings() + } + ) } func makeExperimentalSettings() -> NavigationViewCoordinator { @@ -154,6 +181,12 @@ final class SettingsCoordinator: NavigationCoordinatable { ) } + func makeIndicatorSettings() -> NavigationViewCoordinator { + NavigationViewCoordinator { + IndicatorSettingsView() + } + } + func makeServerDetail(server: ServerState) -> NavigationViewCoordinator { NavigationViewCoordinator { EditServerView(server: server) @@ -161,13 +194,15 @@ final class SettingsCoordinator: NavigationCoordinatable { } func makeVideoPlayerSettings() -> NavigationViewCoordinator { - NavigationViewCoordinator(VideoPlayerSettingsCoordinator()) + NavigationViewCoordinator( + VideoPlayerSettingsCoordinator() + ) } - func makeMaximumBitrateSettings() -> NavigationViewCoordinator { - NavigationViewCoordinator { - MaximumBitrateSettingsView() - } + func makePlaybackQualitySettings() -> NavigationViewCoordinator { + NavigationViewCoordinator( + PlaybackQualitySettingsCoordinator() + ) } #endif diff --git a/Shared/Extensions/Array.swift b/Shared/Extensions/Array.swift index f38d6b3d..202b471b 100644 --- a/Shared/Extensions/Array.swift +++ b/Shared/Extensions/Array.swift @@ -50,3 +50,8 @@ extension Array { return removeFirst() } } + +// extension Array where Element: RawRepresentable { +// +// var asCommaString: String {} +// } diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+VideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+VideoPlayerViewModel.swift index d2eb60c5..e4271a95 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+VideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+VideoPlayerViewModel.swift @@ -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) diff --git a/Shared/Extensions/JellyfinAPI/CodecProfile.swift b/Shared/Extensions/JellyfinAPI/CodecProfile.swift new file mode 100644 index 00000000..b67674d9 --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/CodecProfile.swift @@ -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 applyConditions: () -> [ProfileCondition] = { [] }, + @ArrayBuilder 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 + ) + } +} diff --git a/Shared/Extensions/JellyfinAPI/DeviceProfile.swift b/Shared/Extensions/JellyfinAPI/DeviceProfile.swift new file mode 100644 index 00000000..df1ab0db --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/DeviceProfile.swift @@ -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 + } +} diff --git a/Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile+NativeProfile.swift b/Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile+NativeProfile.swift deleted file mode 100644 index 963bf59b..00000000 --- a/Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile+NativeProfile.swift +++ /dev/null @@ -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 - } -} diff --git a/Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile+SharedCodecProfiles.swift b/Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile+SharedCodecProfiles.swift deleted file mode 100644 index 5203d52f..00000000 --- a/Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile+SharedCodecProfiles.swift +++ /dev/null @@ -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 - } -} diff --git a/Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile+SwiftfinProfile.swift b/Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile+SwiftfinProfile.swift deleted file mode 100644 index 8d66a583..00000000 --- a/Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile+SwiftfinProfile.swift +++ /dev/null @@ -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 - } -} diff --git a/Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile.swift b/Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile.swift deleted file mode 100644 index 0bebd191..00000000 --- a/Shared/Extensions/JellyfinAPI/DeviceProfile/DeviceProfile.swift +++ /dev/null @@ -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 - } -} diff --git a/Shared/Extensions/JellyfinAPI/DirectPlayProfile.swift b/Shared/Extensions/JellyfinAPI/DirectPlayProfile.swift new file mode 100644 index 00000000..2c4c1d83 --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/DirectPlayProfile.swift @@ -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 audioCodecs: () -> String = { "" }, + @CommaStringBuilder videoCodecs: () -> String = { "" }, + @CommaStringBuilder 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 } + ) + } +} diff --git a/Shared/Extensions/JellyfinAPI/MediaSourceInfo/MediaSourceInfo+ItemVideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPI/MediaSourceInfo/MediaSourceInfo+ItemVideoPlayerViewModel.swift index add358c0..6bbd61fc 100644 --- a/Shared/Extensions/JellyfinAPI/MediaSourceInfo/MediaSourceInfo+ItemVideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPI/MediaSourceInfo/MediaSourceInfo+ItemVideoPlayerViewModel.swift @@ -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 diff --git a/Shared/Objects/SpecialFeatureType.swift b/Shared/Extensions/JellyfinAPI/SpecialFeatureType.swift similarity index 100% rename from Shared/Objects/SpecialFeatureType.swift rename to Shared/Extensions/JellyfinAPI/SpecialFeatureType.swift diff --git a/Shared/Extensions/JellyfinAPI/SubtitleProfile.swift b/Shared/Extensions/JellyfinAPI/SubtitleProfile.swift new file mode 100644 index 00000000..6f124efc --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/SubtitleProfile.swift @@ -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 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 containers: () -> [SubtitleFormat] + ) -> [SubtitleProfile] { + containers().map { + SubtitleProfile(container: $0.rawValue, method: method) + } + } +} diff --git a/Shared/Extensions/JellyfinAPI/TranscodingProfile.swift b/Shared/Extensions/JellyfinAPI/TranscodingProfile.swift new file mode 100644 index 00000000..699b69ab --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/TranscodingProfile.swift @@ -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 audioCodecs: () -> String = { "" }, + @CommaStringBuilder videoCodecs: () -> String = { "" }, + @CommaStringBuilder 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 + ) + } +} diff --git a/Shared/Objects/ArrayBuilder.swift b/Shared/Objects/ArrayBuilder.swift new file mode 100644 index 00000000..0a8e34ad --- /dev/null +++ b/Shared/Objects/ArrayBuilder.swift @@ -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 { + + 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 + } +} diff --git a/Shared/Objects/CommaStringBuilder.swift b/Shared/Objects/CommaStringBuilder.swift new file mode 100644 index 00000000..7456844f --- /dev/null +++ b/Shared/Objects/CommaStringBuilder.swift @@ -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 where Component: RawRepresentable { + + 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: ",") + } +} diff --git a/Shared/Objects/VideoPlayerType.swift b/Shared/Objects/CustomDeviceProfileAction.swift similarity index 62% rename from Shared/Objects/VideoPlayerType.swift rename to Shared/Objects/CustomDeviceProfileAction.swift index 936e1ae3..b43db772 100644 --- a/Shared/Objects/VideoPlayerType.swift +++ b/Shared/Objects/CustomDeviceProfileAction.swift @@ -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" } } } diff --git a/Shared/Objects/MediaComponents/AudoCodec.swift b/Shared/Objects/MediaComponents/AudoCodec.swift new file mode 100644 index 00000000..5339aa71 --- /dev/null +++ b/Shared/Objects/MediaComponents/AudoCodec.swift @@ -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" + } + } +} diff --git a/Shared/Objects/MediaComponents/MediaContainer.swift b/Shared/Objects/MediaComponents/MediaContainer.swift new file mode 100644 index 00000000..db61d8c2 --- /dev/null +++ b/Shared/Objects/MediaComponents/MediaContainer.swift @@ -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" + } + } +} diff --git a/Shared/Objects/MediaComponents/SubtitleFormat.swift b/Shared/Objects/MediaComponents/SubtitleFormat.swift new file mode 100644 index 00000000..0f3d55c0 --- /dev/null +++ b/Shared/Objects/MediaComponents/SubtitleFormat.swift @@ -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" + } + } +} diff --git a/Shared/Objects/MediaComponents/VideoCodec.swift b/Shared/Objects/MediaComponents/VideoCodec.swift new file mode 100644 index 00000000..93b92d94 --- /dev/null +++ b/Shared/Objects/MediaComponents/VideoCodec.swift @@ -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" + } + } +} diff --git a/Shared/Objects/PlaybackCompatibility/PlaybackCompatibility+Video.swift b/Shared/Objects/PlaybackCompatibility/PlaybackCompatibility+Video.swift new file mode 100644 index 00000000..70dd0e00 --- /dev/null +++ b/Shared/Objects/PlaybackCompatibility/PlaybackCompatibility+Video.swift @@ -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 + static var compatibilityDirectPlayProfile: [DirectPlayProfile] { + DirectPlayProfile(type: .video) { + AudioCodec.aac + } videoCodecs: { + VideoCodec.h264 + } containers: { + MediaContainer.mp4 + } + } + + @ArrayBuilder + 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 + static var forcedDirectPlayProfile: [DirectPlayProfile] { + DirectPlayProfile(type: .video) + } + } +} diff --git a/Shared/Objects/PlaybackCompatibility/PlaybackCompatibility.swift b/Shared/Objects/PlaybackCompatibility/PlaybackCompatibility.swift new file mode 100644 index 00000000..5cc26a02 --- /dev/null +++ b/Shared/Objects/PlaybackCompatibility/PlaybackCompatibility.swift @@ -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 + } + } +} diff --git a/Shared/Objects/PlaybackDeviceProfile.swift b/Shared/Objects/PlaybackDeviceProfile.swift new file mode 100644 index 00000000..cc3336e5 --- /dev/null +++ b/Shared/Objects/PlaybackDeviceProfile.swift @@ -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) + } + } +} diff --git a/Shared/Objects/StreamType.swift b/Shared/Objects/StreamType.swift index b9239862..8241b696 100644 --- a/Shared/Objects/StreamType.swift +++ b/Shared/Objects/StreamType.swift @@ -8,7 +8,7 @@ import Foundation -enum StreamType: Displayable { +enum StreamType: String, Displayable { case direct case transcode diff --git a/Shared/Objects/VideoPlayerType/VideoPlayerType+Native.swift b/Shared/Objects/VideoPlayerType/VideoPlayerType+Native.swift new file mode 100644 index 00000000..2500733c --- /dev/null +++ b/Shared/Objects/VideoPlayerType/VideoPlayerType+Native.swift @@ -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 + 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 + 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 + 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 + } + } +} diff --git a/Shared/Objects/VideoPlayerType/VideoPlayerType+Shared.swift b/Shared/Objects/VideoPlayerType/VideoPlayerType+Shared.swift new file mode 100644 index 00000000..9e42a4e8 --- /dev/null +++ b/Shared/Objects/VideoPlayerType/VideoPlayerType+Shared.swift @@ -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 + 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 + var responseProfiles: [ResponseProfile] { + ResponseProfile( + container: MediaContainer.m4v.rawValue, + mimeType: "video/mp4", + type: .video + ) + } +} diff --git a/Shared/Objects/VideoPlayerType/VideoPlayerType+Swiftfin.swift b/Shared/Objects/VideoPlayerType/VideoPlayerType+Swiftfin.swift new file mode 100644 index 00000000..1c2c1211 --- /dev/null +++ b/Shared/Objects/VideoPlayerType/VideoPlayerType+Swiftfin.swift @@ -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 + 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 + 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 + 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 + } + } +} diff --git a/Shared/Objects/VideoPlayerType/VideoPlayerType.swift b/Shared/Objects/VideoPlayerType/VideoPlayerType.swift new file mode 100644 index 00000000..38be61c5 --- /dev/null +++ b/Shared/Objects/VideoPlayerType/VideoPlayerType.swift @@ -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 + } + } +} diff --git a/Shared/Services/SwiftfinDefaults.swift b/Shared/Services/SwiftfinDefaults.swift index b23e151b..155b3cb7 100644 --- a/Shared/Services/SwiftfinDefaults.swift +++ b/Shared/Services/SwiftfinDefaults.swift @@ -218,6 +218,13 @@ extension Defaults.Keys { static let timestampType: Key = UserKey("timestampType", default: .split) } + enum Playback { + static let appMaximumBitrate: Key = UserKey("appMaximumBitrate", default: .auto) + static let appMaximumBitrateTest: Key = UserKey("appMaximumBitrateTest", default: .regular) + static let compatibilityMode: Key = UserKey("compatibilityMode", default: .auto) + static let customDeviceProfileAction: Key = UserKey("customDeviceProfileAction", default: .add) + } + enum Subtitle { static let subtitleColor: Key = UserKey("subtitleColor", default: .white) @@ -235,8 +242,6 @@ extension Defaults.Keys { enum Experimental { static let downloads: Key = UserKey("experimentalDownloads", default: false) - static let forceDirectPlay: Key = UserKey("forceDirectPlay", default: false) - static let liveTVForceDirectPlay: Key = UserKey("liveTVForceDirectPlay", default: false) } // tvos specific diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 1fa91a3f..bc7b8d49 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -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. diff --git a/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift b/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift index 9a695fac..642804b1 100644 --- a/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift +++ b/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift @@ -132,5 +132,13 @@ extension StoredValues.Keys { default: "" ) } + + static var customDeviceProfiles: Key<[CustomDeviceProfile]> { + CurrentUserKey( + "customDeviceProfiles", + domain: "customDeviceProfiles", + default: [] + ) + } } } diff --git a/Swiftfin tvOS/Components/OrderedSectionSelectorView.swift b/Swiftfin tvOS/Components/OrderedSectionSelectorView.swift new file mode 100644 index 00000000..cc9effe6 --- /dev/null +++ b/Swiftfin tvOS/Components/OrderedSectionSelectorView.swift @@ -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: 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: 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: 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) + } +} diff --git a/Swiftfin tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/CustomProfileButton.swift b/Swiftfin tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/CustomProfileButton.swift new file mode 100644 index 00000000..6d3284a2 --- /dev/null +++ b/Swiftfin tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/CustomProfileButton.swift @@ -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() + } + } + } +} diff --git a/Swiftfin tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/EditCustomDeviceProfileView.swift b/Swiftfin tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/EditCustomDeviceProfileView.swift new file mode 100644 index 00000000..2f0ead28 --- /dev/null +++ b/Swiftfin tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/Components/EditCustomDeviceProfileView.swift @@ -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 + + private let createProfile: Bool + + private var isValid: Bool { + profile.value.audio.isNotEmpty && + profile.value.video.isNotEmpty && + profile.value.container.isNotEmpty + } + + init(profile: Binding?) { + createProfile = profile == nil + + if let profile { + self._profile = StateObject(wrappedValue: BindingBox(source: profile)) + } else { + let empty = Binding( + 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) + } + } +} diff --git a/Swiftfin tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/CustomDeviceProfileSettingsView.swift b/Swiftfin tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/CustomDeviceProfileSettingsView.swift new file mode 100644 index 00000000..f9295912 --- /dev/null +++ b/Swiftfin tvOS/Views/SettingsView/CustomDeviceProfileSettingsView/CustomDeviceProfileSettingsView.swift @@ -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) + } +} diff --git a/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift b/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift index 65a50896..6b6e5107 100644 --- a/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift +++ b/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift @@ -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) } } diff --git a/Swiftfin tvOS/Views/SettingsView/MaximumBitrateSettingsView.swift b/Swiftfin tvOS/Views/SettingsView/MaximumBitrateSettingsView.swift deleted file mode 100644 index 1f525488..00000000 --- a/Swiftfin tvOS/Views/SettingsView/MaximumBitrateSettingsView.swift +++ /dev/null @@ -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) - } -} diff --git a/Swiftfin tvOS/Views/SettingsView/PlaybackQualitySettingsView.swift b/Swiftfin tvOS/Views/SettingsView/PlaybackQualitySettingsView.swift new file mode 100644 index 00000000..46c67610 --- /dev/null +++ b/Swiftfin tvOS/Views/SettingsView/PlaybackQualitySettingsView.swift @@ -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) + } +} diff --git a/Swiftfin tvOS/Views/SettingsView/SettingsView.swift b/Swiftfin tvOS/Views/SettingsView/SettingsView.swift index 582e0bc2..7ec28275 100644 --- a/Swiftfin tvOS/Views/SettingsView/SettingsView.swift +++ b/Swiftfin tvOS/Views/SettingsView/SettingsView.swift @@ -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 { diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 1167cbb2..ef256cac 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -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 = ""; }; 4E16FD562C01A32700110147 /* LetterPickerOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterPickerOrientation.swift; sourceTree = ""; }; 4E204E582C574FD9004D22A2 /* CustomizeSettingsCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeSettingsCoordinator.swift; sourceTree = ""; }; + 4E2AC4BD2C6C48D200DD600D /* CustomDeviceProfileAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileAction.swift; sourceTree = ""; }; + 4E2AC4C12C6C491200DD600D /* AudoCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudoCodec.swift; sourceTree = ""; }; + 4E2AC4C42C6C492700DD600D /* MediaContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaContainer.swift; sourceTree = ""; }; + 4E2AC4C72C6C493C00DD600D /* SubtitleFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtitleFormat.swift; sourceTree = ""; }; + 4E2AC4CA2C6C494E00DD600D /* VideoCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoCodec.swift; sourceTree = ""; }; + 4E2AC4CD2C6C4A0600DD600D /* PlaybackQualitySettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsCoordinator.swift; sourceTree = ""; }; + 4E2AC4D32C6C4C1200DD600D /* OrderedSectionSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderedSectionSelectorView.swift; sourceTree = ""; }; + 4E2AC4D52C6C4CDC00DD600D /* PlaybackQualitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsView.swift; sourceTree = ""; }; + 4E2AC4D72C6C4D8D00DD600D /* PlaybackQualitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackQualitySettingsView.swift; sourceTree = ""; }; 4E5E48E42AB59806003F1B48 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = ""; }; + 4E71D6882C80910900A0174D /* EditCustomDeviceProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileView.swift; sourceTree = ""; }; 4E73E2A52C41CFD3002D2A78 /* PlaybackBitrateTestSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackBitrateTestSize.swift; sourceTree = ""; }; - 4E73E2AD2C420207002D2A78 /* MaximumBitrateSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaximumBitrateSettingsView.swift; sourceTree = ""; }; - 4E73E2AF2C4211CA002D2A78 /* MaximumBitrateSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaximumBitrateSettingsView.swift; sourceTree = ""; }; 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackBitrate.swift; sourceTree = ""; }; 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemFilter.swift; sourceTree = ""; }; + 4E9A24E52C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileSettingsView.swift; sourceTree = ""; }; + 4E9A24E72C82B6190023DA83 /* CustomProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomProfileButton.swift; sourceTree = ""; }; + 4E9A24EA2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileCoordinator.swift; sourceTree = ""; }; + 4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileView.swift; sourceTree = ""; }; + 4EBE06452C7E9509004A6C03 /* PlaybackCompatibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackCompatibility.swift; sourceTree = ""; }; + 4EBE064C2C7EB6D3004A6C03 /* VideoPlayerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerType.swift; sourceTree = ""; }; + 4EBE06502C7ED0E1004A6C03 /* DeviceProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceProfile.swift; sourceTree = ""; }; + 4EC1C8512C7FDFA300E2879E /* PlaybackDeviceProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackDeviceProfile.swift; sourceTree = ""; }; + 4EC1C8572C80332500E2879E /* EditCustomDeviceProfileCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileCoordinator.swift; sourceTree = ""; }; + 4EC1C8682C808FBB00E2879E /* CustomDeviceProfileSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileSettingsView.swift; sourceTree = ""; }; + 4EC1C86C2C80903A00E2879E /* CustomProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomProfileButton.swift; sourceTree = ""; }; 531690E6267ABD79005D8AB9 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; 531AC8BE26750DE20091C7EB /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = ""; }; 5321753A2671BCFC005491E6 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; @@ -1263,7 +1323,6 @@ E157562F29355B7900976E1F /* UpdateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateView.swift; sourceTree = ""; }; E15756312935642A00976E1F /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = ""; }; E15756332936851D00976E1F /* NativeVideoPlayerSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeVideoPlayerSettingsView.swift; sourceTree = ""; }; - E15756352936856700976E1F /* VideoPlayerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerType.swift; sourceTree = ""; }; E1575EA5293E7D40001665B1 /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = ""; }; E1579EA62B97DC1500A31CA1 /* Eventful.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Eventful.swift; sourceTree = ""; }; E1581E26291EF59800D6C640 /* SplitContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitContentView.swift; sourceTree = ""; }; @@ -1368,6 +1427,9 @@ E18E01FF288749200022598C /* RowDivider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RowDivider.swift; sourceTree = ""; }; E18E0202288749200022598C /* AttributeStyleModifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttributeStyleModifier.swift; sourceTree = ""; }; E18E0203288749200022598C /* BlurView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurView.swift; sourceTree = ""; }; + E19070482C84F2BB0004600E /* ButtonStyle-iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ButtonStyle-iOS.swift"; sourceTree = ""; }; + E190704B2C858CEB0004600E /* VideoPlayerType+Shared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VideoPlayerType+Shared.swift"; sourceTree = ""; }; + E190704E2C8592B40004600E /* PlaybackCompatibility+Video.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlaybackCompatibility+Video.swift"; sourceTree = ""; }; E1921B7328E61914003A5238 /* SpecialFeatureHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialFeatureHStack.swift; sourceTree = ""; }; E1921B7528E63306003A5238 /* GestureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureView.swift; sourceTree = ""; }; E1937A3A288E54AD00CB80AA /* BaseItemDto+Images.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+Images.swift"; sourceTree = ""; }; @@ -1458,6 +1520,14 @@ E1CAF65A2BA345830087D991 /* MediaType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaType.swift; sourceTree = ""; }; E1CAF65B2BA345830087D991 /* MediaViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaViewModel.swift; sourceTree = ""; }; E1CAF6612BA363840087D991 /* UIHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIHostingController.swift; sourceTree = ""; }; + E1CB756E2C80E66700217C76 /* CommaStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommaStringBuilder.swift; sourceTree = ""; }; + E1CB75712C80E71800217C76 /* DirectPlayProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectPlayProfile.swift; sourceTree = ""; }; + E1CB75742C80EAFA00217C76 /* ArrayBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayBuilder.swift; sourceTree = ""; }; + E1CB75772C80ECF100217C76 /* VideoPlayerType+Native.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VideoPlayerType+Native.swift"; sourceTree = ""; }; + E1CB757B2C80F00D00217C76 /* TranscodingProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscodingProfile.swift; sourceTree = ""; }; + E1CB757E2C80F28F00217C76 /* SubtitleProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtitleProfile.swift; sourceTree = ""; }; + E1CB75812C80F66900217C76 /* VideoPlayerType+Swiftfin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VideoPlayerType+Swiftfin.swift"; sourceTree = ""; }; + E1CB758A2C80F9EC00217C76 /* CodecProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodecProfile.swift; sourceTree = ""; }; E1CCF12D28ABF989006CAC9E /* PosterDisplayType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterDisplayType.swift; sourceTree = ""; }; E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterHStack.swift; sourceTree = ""; }; E1CD13EE28EF364100CB46CA /* DetectOrientationModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectOrientationModifier.swift; sourceTree = ""; }; @@ -1468,10 +1538,6 @@ E1D3044328D1991900587289 /* LibraryViewTypeToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewTypeToggle.swift; sourceTree = ""; }; E1D37F472B9C648E00343D2B /* MaxHeightText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaxHeightText.swift; sourceTree = ""; }; E1D37F4A2B9CEA5C00343D2B /* ImageSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSource.swift; sourceTree = ""; }; - E1D37F4D2B9CEDC400343D2B /* DeviceProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProfile.swift; sourceTree = ""; }; - E1D37F512B9CEF1E00343D2B /* DeviceProfile+SharedCodecProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceProfile+SharedCodecProfiles.swift"; sourceTree = ""; }; - E1D37F542B9CEF4600343D2B /* DeviceProfile+NativeProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceProfile+NativeProfile.swift"; sourceTree = ""; }; - E1D37F572B9CEF4B00343D2B /* DeviceProfile+SwiftfinProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceProfile+SwiftfinProfile.swift"; sourceTree = ""; }; E1D4BF7B2719D05000A11E64 /* AppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsView.swift; sourceTree = ""; }; E1D4BF802719D22800A11E64 /* AppAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAppearance.swift; sourceTree = ""; }; E1D4BF892719D3D000A11E64 /* AppSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsCoordinator.swift; sourceTree = ""; }; @@ -1689,6 +1755,17 @@ path = Components; sourceTree = ""; }; + 4E2AC4C02C6C48EB00DD600D /* MediaComponents */ = { + isa = PBXGroup; + children = ( + 4E2AC4C12C6C491200DD600D /* AudoCodec.swift */, + 4E2AC4C42C6C492700DD600D /* MediaContainer.swift */, + 4E2AC4C72C6C493C00DD600D /* SubtitleFormat.swift */, + 4E2AC4CA2C6C494E00DD600D /* VideoCodec.swift */, + ); + path = MediaComponents; + sourceTree = ""; + }; 4E3A785D2C3B87A400D33C11 /* PlaybackBitrate */ = { isa = PBXGroup; children = ( @@ -1698,6 +1775,42 @@ path = PlaybackBitrate; sourceTree = ""; }; + 4E9A24E32C82B4700023DA83 /* CustomDeviceProfileSettingsView */ = { + isa = PBXGroup; + children = ( + 4E9A24E42C82B5440023DA83 /* Components */, + 4E9A24E52C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift */, + ); + path = CustomDeviceProfileSettingsView; + sourceTree = ""; + }; + 4E9A24E42C82B5440023DA83 /* Components */ = { + isa = PBXGroup; + children = ( + 4E9A24E72C82B6190023DA83 /* CustomProfileButton.swift */, + 4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift */, + ); + path = Components; + sourceTree = ""; + }; + 4EC1C86A2C80900B00E2879E /* CustomDeviceProfileSettingsView */ = { + isa = PBXGroup; + children = ( + 4EC1C86B2C80902200E2879E /* Components */, + 4EC1C8682C808FBB00E2879E /* CustomDeviceProfileSettingsView.swift */, + ); + path = CustomDeviceProfileSettingsView; + sourceTree = ""; + }; + 4EC1C86B2C80902200E2879E /* Components */ = { + isa = PBXGroup; + children = ( + 4EC1C86C2C80903A00E2879E /* CustomProfileButton.swift */, + 4E71D6882C80910900A0174D /* EditCustomDeviceProfileView.swift */, + ); + path = Components; + sourceTree = ""; + }; 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 = ""; @@ -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 = ""; }; + E190704A2C858B7B0004600E /* PlaybackCompatibility */ = { + isa = PBXGroup; + children = ( + 4EBE06452C7E9509004A6C03 /* PlaybackCompatibility.swift */, + E190704E2C8592B40004600E /* PlaybackCompatibility+Video.swift */, + ); + path = PlaybackCompatibility; + sourceTree = ""; + }; 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 = ""; }; - 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 = ""; }; 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; diff --git a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a9143492..1cae69ae 100644 --- a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "54fc43873cff9b3db2ad273a82066d201e4ea59316a81526b530004e4d98b974", + "originHash" : "323b2ad9aaa9c000faf264d68272f0e9fab1349d9f910a0b95ee6aea10460f31", "pins" : [ { "identity" : "blurhashkit", diff --git a/Swiftfin/Components/OrderedSectionSelectorView.swift b/Swiftfin/Components/OrderedSectionSelectorView.swift index b91ed34e..36b58a5d 100644 --- a/Swiftfin/Components/OrderedSectionSelectorView.swift +++ b/Swiftfin/Components/OrderedSectionSelectorView.swift @@ -28,6 +28,9 @@ struct OrderedSectionSelectorView: View { } private func select(element: Element) { + + UIDevice.impact(.light) + if selection.value.contains(element) { selection.value.removeAll(where: { $0 == element }) } else { diff --git a/Swiftfin/Extensions/ButtonStyle-iOS.swift b/Swiftfin/Extensions/ButtonStyle-iOS.swift new file mode 100644 index 00000000..6cfdf19b --- /dev/null +++ b/Swiftfin/Extensions/ButtonStyle-iOS.swift @@ -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) + } +} diff --git a/Swiftfin/Extensions/Label-iOS.swift b/Swiftfin/Extensions/Label-iOS.swift index cb3ab26c..eae5a6a6 100644 --- a/Swiftfin/Extensions/Label-iOS.swift +++ b/Swiftfin/Extensions/Label-iOS.swift @@ -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 { + + static func sectionFooterWithImage(imageStyle: ImageStyle) -> SectionFooterWithImageLabelStyle { + SectionFooterWithImageLabelStyle(imageStyle: imageStyle) + } +} + +struct SectionFooterWithImageLabelStyle: LabelStyle { + + let imageStyle: ImageStyle + + func makeBody(configuration: Configuration) -> some View { + HStack { + configuration.icon + .foregroundStyle(imageStyle) + .backport + .fontWeight(.bold) + + configuration.title + } + } +} diff --git a/Swiftfin/Views/SettingsView/CustomDeviceProfileSettingsView/Components/CustomProfileButton.swift b/Swiftfin/Views/SettingsView/CustomDeviceProfileSettingsView/Components/CustomProfileButton.swift new file mode 100644 index 00000000..f6fc5b67 --- /dev/null +++ b/Swiftfin/Views/SettingsView/CustomDeviceProfileSettingsView/Components/CustomProfileButton.swift @@ -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) + } + } +} diff --git a/Swiftfin/Views/SettingsView/CustomDeviceProfileSettingsView/Components/EditCustomDeviceProfileView.swift b/Swiftfin/Views/SettingsView/CustomDeviceProfileSettingsView/Components/EditCustomDeviceProfileView.swift new file mode 100644 index 00000000..0a9a3bc5 --- /dev/null +++ b/Swiftfin/Views/SettingsView/CustomDeviceProfileSettingsView/Components/EditCustomDeviceProfileView.swift @@ -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? + + private var isValid: Bool { + profile.audio.isNotEmpty && + profile.video.isNotEmpty && + profile.container.isNotEmpty + } + + init(profile: Binding?) { + + 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() + } + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/CustomDeviceProfileSettingsView/CustomDeviceProfileSettingsView.swift b/Swiftfin/Views/SettingsView/CustomDeviceProfileSettingsView/CustomDeviceProfileSettingsView.swift new file mode 100644 index 00000000..6e881ad3 --- /dev/null +++ b/Swiftfin/Views/SettingsView/CustomDeviceProfileSettingsView/CustomDeviceProfileSettingsView.swift @@ -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) + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift b/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift index 46cfa95d..162f984e 100644 --- a/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift +++ b/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift @@ -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) } } diff --git a/Swiftfin/Views/SettingsView/MaximumBitrateSettingsView.swift b/Swiftfin/Views/SettingsView/MaximumBitrateSettingsView.swift deleted file mode 100644 index 2ade4dd7..00000000 --- a/Swiftfin/Views/SettingsView/MaximumBitrateSettingsView.swift +++ /dev/null @@ -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) - } -} diff --git a/Swiftfin/Views/SettingsView/PlaybackQualitySettingsView.swift b/Swiftfin/Views/SettingsView/PlaybackQualitySettingsView.swift new file mode 100644 index 00000000..11e7130e --- /dev/null +++ b/Swiftfin/Views/SettingsView/PlaybackQualitySettingsView.swift @@ -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) + } +} diff --git a/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift b/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift index b70eec14..84189d26 100644 --- a/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift +++ b/Swiftfin/Views/SettingsView/SettingsView/SettingsView.swift @@ -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 { diff --git a/Swiftfin/Views/SettingsView/UserProfileSettingsView/ResetUserPasswordView.swift b/Swiftfin/Views/SettingsView/UserProfileSettingsView/ResetUserPasswordView.swift index 25ef2ffc..a96bb59b 100644 --- a/Swiftfin/Views/SettingsView/UserProfileSettingsView/ResetUserPasswordView.swift +++ b/Swiftfin/Views/SettingsView/UserProfileSettingsView/ResetUserPasswordView.swift @@ -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)) } } diff --git a/Swiftfin/Views/UserSignInView/UserSignInView.swift b/Swiftfin/Views/UserSignInView/UserSignInView.swift index 6929c3f9..4d63febc 100644 --- a/Swiftfin/Views/UserSignInView/UserSignInView.swift +++ b/Swiftfin/Views/UserSignInView/UserSignInView.swift @@ -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() } diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index 077a43620c6fca2f02238662d28ca507797812df..d67f9b0d748afac45fc7209338c62b7c627f336f 100644 GIT binary patch delta 3011 zcmb_eNoy2A7_C79nrI}6F%EHQBN8!!2EB*|$>I`FqcN9EgF2Zd8JvxtiIRgff`352 zgP@4`2gKmnyT8GcH*bO`ufA8+ZCAB3QGV5U;bLQKR%=`PfOi{w>5xKb7 zFN6Mx%X6xv+V~GtO;uDKqn7fpvWAhTs`#y|RXz7qLzS()aW$>hvC~lFGMPKHybjHh zDr$)ae#_dPnHy_)^-$%pTE*{*w(V&dLj%PE{3ii zVUH(r64rWQnX$)_MRA;BVA`#xrXV`jJ$zM??{`NhJ=IK7f+mlFPNrd=4w6HRfFPNn zq0mYjGIme}t9W)Iu7@k*5_Spt zKbNjY-vJp|7?Q!>L3vl|m!Pt52QV$0yN2ay=}K(@sOGHH)Z;0rqSnS~AUw#%BfN z4MfZ9U}JDrOO@2>Ad~_v5?}4Se7!l2TV{BSH25duX~$#%QW*n*i{Cu3kanOhYuvzY zD}*XdbP}{#?O(#2Lq!NQGI6ZeEzHo6T%wSG-8RDmGIiSi(YTZ!Rqb%cvi<4}@}@j; zb^9)zULYSIjPzMY@@#K5Oc$P1XP~rwrxx%vKAa#N9&d&?r>N6Gw1rj@leKGvJ2o0- zAiW0Iu``*T8iSe~ZSN!&(>k;`pR~;xoi{0fSY~fBkBypMVQb#9waVmTc791G(YDe^ zTTLYFbkdi0My3{y?{QY;^#0NFt~IwLjx=I*WMv>XD(4pWr))m1j>s8*w_nkZv9>;& zsEwc+bzO-7-!2a&cu5{%MT|+NhBSFFMd&qT(?3cQ4sW22B&aL+5@iXIvcemREvFT= z&Usjg?_11Eu^zqf$rA&GUERDUS~^ZrqKU!S^|X9Tmi@gGHAs-y`2ZtH8nhc}J}f;% z(8hY8+KhXGHH{xXX^Kvvffb&JL1@1)Y#SD+i417!P1e~D-#RluCBBYqH@M|Lw}H&Z)$ZHIV=S delta 54 zcmaF&o$