No Tab Characters and Before First for Argument and Parameter Wrapping (#482)

This commit is contained in:
Ethan Pippin 2022-07-16 07:46:25 -06:00 committed by GitHub
parent 88f350b71e
commit cfb3aa1faa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
253 changed files with 19200 additions and 18370 deletions

View File

@ -7,7 +7,7 @@ on:
jobs:
build:
name: "Lint 🧹"
runs-on: macos-latest
runs-on: macos-12
steps:
- name: Checkout

View File

@ -1,16 +1,15 @@
# version: 0.47.5
# version: 0.49.11
--swiftversion 5.5
--indent tab
--tabwidth 4
--xcodeindentation enabled
--semicolons never
--stripunusedargs closure-only
--maxwidth 140
--assetliterals visual-width
--wraparguments after-first
--wrapparameters after-first
--wraparguments before-first
--wrapparameters before-first
--wrapcollections before-first
--wrapconditions after-first
--funcattributes prev-line
@ -44,7 +43,6 @@
redundantClosure, \
redundantType
--exclude Pods
--exclude Shared/Generated/Strings.swift
--header "\nSwiftfin is subject to the terms of the Mozilla Public\nLicense, v2.0. If a copy of the MPL was not distributed with this\nfile, you can obtain one at https://mozilla.org/MPL/2.0/.\n\nCopyright (c) {year} Jellyfin & Jellyfin Contributors\n"

View File

@ -46,9 +46,11 @@ final class LibraryCoordinator: NavigationCoordinatable {
}
func makeFilter(params: FilterCoordinatorParams) -> NavigationViewCoordinator<FilterCoordinator> {
NavigationViewCoordinator(FilterCoordinator(filters: params.filters,
NavigationViewCoordinator(FilterCoordinator(
filters: params.filters,
enabledFilterType: params.enabledFilterType,
parentId: params.parentId))
parentId: params.parentId
))
}
func makeItem(item: BaseItemDto) -> ItemCoordinator {

View File

@ -41,21 +41,29 @@ enum NetworkError: Error {
// https://developer.apple.com/documentation/foundation/1508628-url_loading_system_error_codes
switch err._code {
case -1001:
errorMessage = ErrorMessage(code: err._code,
errorMessage = ErrorMessage(
code: err._code,
title: L10n.error,
message: L10n.networkTimedOut)
message: L10n.networkTimedOut
)
case -1003:
errorMessage = ErrorMessage(code: err._code,
errorMessage = ErrorMessage(
code: err._code,
title: L10n.error,
message: L10n.unableToFindHost)
message: L10n.unableToFindHost
)
case -1004:
errorMessage = ErrorMessage(code: err._code,
errorMessage = ErrorMessage(
code: err._code,
title: L10n.error,
message: L10n.cannotConnectToHost)
message: L10n.cannotConnectToHost
)
default:
errorMessage = ErrorMessage(code: err._code,
errorMessage = ErrorMessage(
code: err._code,
title: L10n.error,
message: L10n.unknownError)
message: L10n.unknownError
)
}
}
@ -68,9 +76,11 @@ enum NetworkError: Error {
// Not implemented as has not run into one of these errors as time of writing
switch response {
case .error:
errorMessage = ErrorMessage(code: 0,
errorMessage = ErrorMessage(
code: 0,
title: L10n.error,
message: "An HTTP URL error has occurred")
message: "An HTTP URL error has occurred"
)
}
return errorMessage
@ -85,13 +95,17 @@ enum NetworkError: Error {
// Generic HTTP status codes
switch code {
case 401:
errorMessage = ErrorMessage(code: code,
errorMessage = ErrorMessage(
code: code,
title: L10n.unauthorized,
message: L10n.unauthorizedUser)
message: L10n.unauthorizedUser
)
default:
errorMessage = ErrorMessage(code: code,
errorMessage = ErrorMessage(
code: code,
title: L10n.error,
message: displayMessage ?? L10n.unknownError)
message: displayMessage ?? L10n.unknownError
)
}
}

View File

@ -69,9 +69,19 @@ public extension UIImage {
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue)
guard let provider = CGDataProvider(data: data) else { return nil }
guard let cgImage = CGImage(width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 24, bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: bitmapInfo, provider: provider, decode: nil,
shouldInterpolate: true, intent: .defaultIntent) else { return nil }
guard let cgImage = CGImage(
width: width,
height: height,
bitsPerComponent: 8,
bitsPerPixel: 24,
bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: bitmapInfo,
provider: provider,
decode: nil,
shouldInterpolate: true,
intent: .defaultIntent
) else { return nil }
self.init(cgImage: cgImage)
}
@ -89,9 +99,11 @@ private func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float
let quantG = (value / 19) % 19
let quantB = value % 19
let rgb = (signPow((Float(quantR) - 9) / 9, 2) * maximumValue,
let rgb = (
signPow((Float(quantR) - 9) / 9, 2) * maximumValue,
signPow((Float(quantG) - 9) / 9, 2) * maximumValue,
signPow((Float(quantB) - 9) / 9, 2) * maximumValue)
signPow((Float(quantB) - 9) / 9, 2) * maximumValue
)
return rgb
}

View File

@ -22,18 +22,22 @@ extension BaseItemDto {
builder.setMaxBitrate(bitrate: tempOverkillBitrate)
let profile = builder.buildProfile()
let getPostedPlaybackInfoRequest = GetPostedPlaybackInfoRequest(userId: SessionManager.main.currentLogin.user.id,
let getPostedPlaybackInfoRequest = GetPostedPlaybackInfoRequest(
userId: SessionManager.main.currentLogin.user.id,
maxStreamingBitrate: tempOverkillBitrate,
startTimeTicks: self.userData?.playbackPositionTicks ?? 0,
deviceProfile: profile,
autoOpenLiveStream: true)
autoOpenLiveStream: true
)
return MediaInfoAPI.getPostedPlaybackInfo(itemId: self.id!,
return MediaInfoAPI.getPostedPlaybackInfo(
itemId: self.id!,
userId: SessionManager.main.currentLogin.user.id,
maxStreamingBitrate: tempOverkillBitrate,
startTimeTicks: self.userData?.playbackPositionTicks ?? 0,
autoOpenLiveStream: true,
getPostedPlaybackInfoRequest: getPostedPlaybackInfoRequest)
getPostedPlaybackInfoRequest: getPostedPlaybackInfoRequest
)
.map { response -> [VideoPlayerViewModel] in
let mediaSources = response.mediaSources!
@ -63,24 +67,29 @@ extension BaseItemDto {
mediaSourceID = self.id!
}
let directStreamBuilder = VideosAPI.getVideoStreamWithRequestBuilder(itemId: self.id!,
let directStreamBuilder = VideosAPI.getVideoStreamWithRequestBuilder(
itemId: self.id!,
_static: true,
tag: self.etag,
playSessionId: response.playSessionId,
minSegments: 6,
mediaSourceId: mediaSourceID)
mediaSourceId: mediaSourceID
)
directStreamURL = URL(string: directStreamBuilder.URLString)!
if let transcodeURL = currentMediaSource.transcodingUrl {
streamType = .transcode
transcodedStreamURL = URLComponents(string: SessionManager.main.currentLogin.server.currentURI
.appending(transcodeURL))!
transcodedStreamURL = URLComponents(
string: SessionManager.main.currentLogin.server.currentURI
.appending(transcodeURL)
)!
} else {
streamType = .direct
transcodedStreamURL = nil
}
let hlsStreamBuilder = DynamicHlsAPI.getMasterHlsVideoPlaylistWithRequestBuilder(itemId: id ?? "",
let hlsStreamBuilder = DynamicHlsAPI.getMasterHlsVideoPlaylistWithRequestBuilder(
itemId: id ?? "",
mediaSourceId: id ?? "",
_static: true,
tag: currentMediaSource.eTag,
@ -98,7 +107,8 @@ extension BaseItemDto {
transcodingMaxAudioChannels: 6,
videoCodec: videoStream?.codec,
videoStreamIndex: videoStream?.index,
enableAdaptiveBitrateStreaming: true)
enableAdaptiveBitrateStreaming: true
)
var hlsStreamComponents = URLComponents(string: hlsStreamBuilder.URLString)!
hlsStreamComponents.addQueryItem(name: "api_key", value: SessionManager.main.currentLogin.user.accessToken)
@ -136,7 +146,8 @@ extension BaseItemDto {
fileName = String(lastInPath)
}
let videoPlayerViewModel = VideoPlayerViewModel(item: modifiedSelfItem,
let videoPlayerViewModel = VideoPlayerViewModel(
item: modifiedSelfItem,
title: modifiedSelfItem.name ?? "",
subtitle: subtitle,
directStreamURL: directStreamURL,
@ -157,7 +168,8 @@ extension BaseItemDto {
shouldShowAutoPlay: shouldShowAutoPlay,
container: currentMediaSource.container ?? "",
filename: fileName,
versionName: currentMediaSource.name)
versionName: currentMediaSource.name
)
viewModels.append(videoPlayerViewModel)
}
@ -177,18 +189,22 @@ extension BaseItemDto {
builder.setMaxBitrate(bitrate: tempOverkillBitrate)
let profile = builder.buildProfile()
let getPostedPlaybackInfoRequest = GetPostedPlaybackInfoRequest(userId: SessionManager.main.currentLogin.user.id,
let getPostedPlaybackInfoRequest = GetPostedPlaybackInfoRequest(
userId: SessionManager.main.currentLogin.user.id,
maxStreamingBitrate: tempOverkillBitrate,
startTimeTicks: self.userData?.playbackPositionTicks ?? 0,
deviceProfile: profile,
autoOpenLiveStream: true)
autoOpenLiveStream: true
)
return MediaInfoAPI.getPostedPlaybackInfo(itemId: self.id!,
return MediaInfoAPI.getPostedPlaybackInfo(
itemId: self.id!,
userId: SessionManager.main.currentLogin.user.id,
maxStreamingBitrate: tempOverkillBitrate,
startTimeTicks: self.userData?.playbackPositionTicks ?? 0,
autoOpenLiveStream: true,
getPostedPlaybackInfoRequest: getPostedPlaybackInfoRequest)
getPostedPlaybackInfoRequest: getPostedPlaybackInfoRequest
)
.map { response -> [VideoPlayerViewModel] in
let mediaSources = response.mediaSources!
@ -218,24 +234,29 @@ extension BaseItemDto {
mediaSourceID = self.id!
}
let directStreamBuilder = VideosAPI.getVideoStreamWithRequestBuilder(itemId: self.id!,
let directStreamBuilder = VideosAPI.getVideoStreamWithRequestBuilder(
itemId: self.id!,
_static: true,
tag: self.etag,
playSessionId: response.playSessionId,
minSegments: 6,
mediaSourceId: mediaSourceID)
mediaSourceId: mediaSourceID
)
directStreamURL = URL(string: directStreamBuilder.URLString)!
if let transcodeURL = currentMediaSource.transcodingUrl, !Defaults[.Experimental.liveTVForceDirectPlay] {
streamType = .transcode
transcodedStreamURL = URLComponents(string: SessionManager.main.currentLogin.server.currentURI
.appending(transcodeURL))!
transcodedStreamURL = URLComponents(
string: SessionManager.main.currentLogin.server.currentURI
.appending(transcodeURL)
)!
} else {
streamType = .direct
transcodedStreamURL = nil
}
let hlsStreamBuilder = DynamicHlsAPI.getMasterHlsVideoPlaylistWithRequestBuilder(itemId: id ?? "",
let hlsStreamBuilder = DynamicHlsAPI.getMasterHlsVideoPlaylistWithRequestBuilder(
itemId: id ?? "",
mediaSourceId: id ?? "",
_static: true,
tag: currentMediaSource.eTag,
@ -253,7 +274,8 @@ extension BaseItemDto {
transcodingMaxAudioChannels: 6,
videoCodec: videoStream?.codec,
videoStreamIndex: videoStream?.index,
enableAdaptiveBitrateStreaming: true)
enableAdaptiveBitrateStreaming: true
)
var hlsStreamComponents = URLComponents(string: hlsStreamBuilder.URLString)!
hlsStreamComponents.addQueryItem(name: "api_key", value: SessionManager.main.currentLogin.user.accessToken)
@ -291,7 +313,8 @@ extension BaseItemDto {
fileName = String(lastInPath)
}
let videoPlayerViewModel = VideoPlayerViewModel(item: modifiedSelfItem,
let videoPlayerViewModel = VideoPlayerViewModel(
item: modifiedSelfItem,
title: modifiedSelfItem.name ?? "",
subtitle: subtitle,
directStreamURL: directStreamURL,
@ -312,7 +335,8 @@ extension BaseItemDto {
shouldShowAutoPlay: shouldShowAutoPlay,
container: currentMediaSource.container ?? "",
filename: fileName,
versionName: currentMediaSource.name)
versionName: currentMediaSource.name
)
viewModels.append(videoPlayerViewModel)
}

View File

@ -88,21 +88,25 @@ public extension BaseItemDto {
let x = UIScreen.main.nativeScale * CGFloat(maxWidth)
let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: imageItemId,
let urlString = ImageAPI.getItemImageWithRequestBuilder(
itemId: imageItemId,
imageType: imageType,
maxWidth: Int(x),
quality: 96,
tag: imageTag).URLString
tag: imageTag
).URLString
return URL(string: urlString)!
}
func getThumbImage(maxWidth: Int) -> URL {
let x = UIScreen.main.nativeScale * CGFloat(maxWidth)
let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: id ?? "",
let urlString = ImageAPI.getItemImageWithRequestBuilder(
itemId: id ?? "",
imageType: .thumb,
maxWidth: Int(x),
quality: 96).URLString
quality: 96
).URLString
return URL(string: urlString)!
}
@ -115,11 +119,13 @@ public extension BaseItemDto {
func getSeriesBackdropImage(maxWidth: Int) -> URL {
let x = UIScreen.main.nativeScale * CGFloat(maxWidth)
let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: parentBackdropItemId ?? "",
let urlString = ImageAPI.getItemImageWithRequestBuilder(
itemId: parentBackdropItemId ?? "",
imageType: .backdrop,
maxWidth: Int(x),
quality: 96,
tag: parentBackdropImageTags?.first).URLString
tag: parentBackdropImageTags?.first
).URLString
return URL(string: urlString)!
}
@ -129,21 +135,25 @@ public extension BaseItemDto {
}
let x = UIScreen.main.nativeScale * CGFloat(maxWidth)
let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: seriesId,
let urlString = ImageAPI.getItemImageWithRequestBuilder(
itemId: seriesId,
imageType: .primary,
maxWidth: Int(x),
quality: 96,
tag: seriesPrimaryImageTag).URLString
tag: seriesPrimaryImageTag
).URLString
return URL(string: urlString)!
}
func getSeriesThumbImage(maxWidth: Int) -> URL {
let x = UIScreen.main.nativeScale * CGFloat(maxWidth)
let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: seriesId ?? "",
let urlString = ImageAPI.getItemImageWithRequestBuilder(
itemId: seriesId ?? "",
imageType: .thumb,
maxWidth: Int(x),
quality: 96,
tag: seriesPrimaryImageTag).URLString
tag: seriesPrimaryImageTag
).URLString
return URL(string: urlString)!
}
@ -159,11 +169,13 @@ public extension BaseItemDto {
let x = UIScreen.main.nativeScale * CGFloat(maxWidth)
let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: imageItemId,
let urlString = ImageAPI.getItemImageWithRequestBuilder(
itemId: imageItemId,
imageType: imageType,
maxWidth: Int(x),
quality: 96,
tag: imageTag).URLString
tag: imageTag
).URLString
return URL(string: urlString)!
}
@ -366,10 +378,12 @@ public extension BaseItemDto {
var chapterImageURLs: [URL] = []
for chapterIndex in 0 ..< chapters.count {
let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: id ?? "",
let urlString = ImageAPI.getItemImageWithRequestBuilder(
itemId: id ?? "",
imageType: .chapter,
maxWidth: maxWidth,
imageIndex: chapterIndex).URLString
imageIndex: chapterIndex
).URLString
chapterImageURLs.append(URL(string: urlString)!)
}

View File

@ -17,11 +17,13 @@ extension BaseItemPerson {
func getImage(baseURL: String, maxWidth: Int) -> URL {
let x = UIScreen.main.nativeScale * CGFloat(maxWidth)
let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: id ?? "",
let urlString = ImageAPI.getItemImageWithRequestBuilder(
itemId: id ?? "",
imageType: .primary,
maxWidth: Int(x),
quality: 96,
tag: primaryImageTag).URLString
tag: primaryImageTag
).URLString
return URL(string: urlString)!
}

View File

@ -17,7 +17,9 @@ extension VLCMediaPlayer {
///
/// This is pretty hacky until VLCKit 4 has a public API to support this
func setSubtitleSize(_ size: SubtitleSize) {
perform(Selector(("setTextRendererFontSize:")),
with: size.textRendererFontSize)
perform(
Selector(("setTextRendererFontSize:")),
with: size.textRendererFontSize
)
}
}

View File

@ -55,28 +55,40 @@ class DeviceProfileBuilder {
// Device supports Dolby Digital (AC3, EAC3)
if supportsFeature(minimumSupported: .A8X) {
if supportsFeature(minimumSupported: .A9) {
directPlayProfiles = [DirectPlayProfile(container: "mov,mp4,mkv,webm", audioCodec: "aac,mp3,wav,ac3,eac3,flac,opus",
directPlayProfiles = [DirectPlayProfile(
container: "mov,mp4,mkv,webm",
audioCodec: "aac,mp3,wav,ac3,eac3,flac,opus",
videoCodec: "hevc,h264,hev1,mpeg4,vp9",
type: .video)] // HEVC/H.264 with Dolby Digital
type: .video
)] // HEVC/H.264 with Dolby Digital
} else {
directPlayProfiles = [DirectPlayProfile(container: "mov,mp4,mkv,webm", audioCodec: "ac3,eac3,aac,mp3,wav,opus",
videoCodec: "h264,mpeg4,vp9", type: .video)] // H.264 with Dolby Digital
directPlayProfiles = [DirectPlayProfile(
container: "mov,mp4,mkv,webm",
audioCodec: "ac3,eac3,aac,mp3,wav,opus",
videoCodec: "h264,mpeg4,vp9",
type: .video
)] // H.264 with Dolby Digital
}
}
// Device supports Dolby Vision?
if supportsFeature(minimumSupported: .A10X) {
directPlayProfiles = [DirectPlayProfile(container: "mov,mp4,mkv,webm", audioCodec: "aac,mp3,wav,ac3,eac3,flac,opus",
directPlayProfiles = [DirectPlayProfile(
container: "mov,mp4,mkv,webm",
audioCodec: "aac,mp3,wav,ac3,eac3,flac,opus",
videoCodec: "dvhe,dvh1,h264,hevc,hev1,mpeg4,vp9",
type: .video)] // H.264/HEVC with Dolby Digital - No Atmos - Vision
type: .video
)] // H.264/HEVC with Dolby Digital - No Atmos - Vision
}
// Device supports Dolby Atmos?
if supportsFeature(minimumSupported: .A12) {
directPlayProfiles = [DirectPlayProfile(container: "mov,mp4,mkv,webm",
directPlayProfiles = [DirectPlayProfile(
container: "mov,mp4,mkv,webm",
audioCodec: "aac,mp3,wav,ac3,eac3,flac,truehd,dts,dca,opus",
videoCodec: "h264,hevc,dvhe,dvh1,h264,hevc,hev1,mpeg4,vp9",
type: .video)] // H.264/HEVC with Dolby Digital & Atmos - Vision
type: .video
)] // H.264/HEVC with Dolby Digital & Atmos - Vision
}
// Build transcoding profiles
@ -86,32 +98,57 @@ class DeviceProfileBuilder {
// Device supports Dolby Digital (AC3, EAC3)
if supportsFeature(minimumSupported: .A8X) {
if supportsFeature(minimumSupported: .A9) {
transcodingProfiles = [TranscodingProfile(container: "ts", type: .video, videoCodec: "h264,hevc,mpeg4",
audioCodec: "aac,mp3,wav,eac3,ac3,flac,opus", _protocol: "hls",
context: .streaming, maxAudioChannels: "6", minSegments: 2,
breakOnNonKeyFrames: true)]
transcodingProfiles = [TranscodingProfile(
container: "ts",
type: .video,
videoCodec: "h264,hevc,mpeg4",
audioCodec: "aac,mp3,wav,eac3,ac3,flac,opus",
_protocol: "hls",
context: .streaming,
maxAudioChannels: "6",
minSegments: 2,
breakOnNonKeyFrames: true
)]
} else {
transcodingProfiles = [TranscodingProfile(container: "ts", type: .video, videoCodec: "h264,mpeg4",
audioCodec: "aac,mp3,wav,eac3,ac3,opus", _protocol: "hls",
context: .streaming, maxAudioChannels: "6", minSegments: 2,
breakOnNonKeyFrames: true)]
transcodingProfiles = [TranscodingProfile(
container: "ts",
type: .video,
videoCodec: "h264,mpeg4",
audioCodec: "aac,mp3,wav,eac3,ac3,opus",
_protocol: "hls",
context: .streaming,
maxAudioChannels: "6",
minSegments: 2,
breakOnNonKeyFrames: true
)]
}
}
// Device supports FLAC?
if supportsFeature(minimumSupported: .A10X) {
transcodingProfiles = [TranscodingProfile(container: "ts", type: .video, videoCodec: "hevc,h264,mpeg4",
audioCodec: "aac,mp3,wav,ac3,eac3,flac,opus", _protocol: "hls",
context: .streaming, maxAudioChannels: "6", minSegments: 2,
breakOnNonKeyFrames: true)]
transcodingProfiles = [TranscodingProfile(
container: "ts",
type: .video,
videoCodec: "hevc,h264,mpeg4",
audioCodec: "aac,mp3,wav,ac3,eac3,flac,opus",
_protocol: "hls",
context: .streaming,
maxAudioChannels: "6",
minSegments: 2,
breakOnNonKeyFrames: true
)]
}
var codecProfiles: [CodecProfile] = []
let h264CodecConditions: [ProfileCondition] = [
ProfileCondition(condition: .notEquals, property: .isAnamorphic, value: "true", isRequired: false),
ProfileCondition(condition: .equalsAny, property: .videoProfile, value: "high|main|baseline|constrained baseline",
isRequired: false),
ProfileCondition(
condition: .equalsAny,
property: .videoProfile,
value: "high|main|baseline|constrained baseline",
isRequired: false
),
ProfileCondition(condition: .lessThanEqual, property: .videoLevel, value: "80", isRequired: false),
ProfileCondition(condition: .notEquals, property: .isInterlaced, value: "true", isRequired: false),
]
@ -147,12 +184,17 @@ class DeviceProfileBuilder {
let responseProfiles: [ResponseProfile] = [ResponseProfile(container: "m4v", type: .video, mimeType: "video/mp4")]
let profile = ClientCapabilitiesDeviceProfile(maxStreamingBitrate: maxStreamingBitrate, maxStaticBitrate: maxStaticBitrate,
let profile = ClientCapabilitiesDeviceProfile(
maxStreamingBitrate: maxStreamingBitrate,
maxStaticBitrate: maxStaticBitrate,
musicStreamingTranscodingBitrate: musicStreamingTranscodingBitrate,
directPlayProfiles: directPlayProfiles, transcodingProfiles: transcodingProfiles,
directPlayProfiles: directPlayProfiles,
transcodingProfiles: transcodingProfiles,
containerProfiles: [],
codecProfiles: codecProfiles, responseProfiles: responseProfiles,
subtitleProfiles: subtitleProfiles)
codecProfiles: codecProfiles,
responseProfiles: responseProfiles,
subtitleProfiles: subtitleProfiles
)
return profile
}

View File

@ -18,17 +18,21 @@ class LogManager {
let logsDirectory = getDocumentsDirectory().appendingPathComponent("logs", isDirectory: true)
do {
try FileManager.default.createDirectory(atPath: logsDirectory.path,
try FileManager.default.createDirectory(
atPath: logsDirectory.path,
withIntermediateDirectories: true,
attributes: nil)
attributes: nil
)
} catch {
// logs directory already created
}
let logFileURL = logsDirectory.appendingPathComponent("swiftfin_log.log")
let fileRotationLogger = try! FileRotationLogger("org.jellyfin.swiftfin.logger.file-rotation",
fileURL: logFileURL)
let fileRotationLogger = try! FileRotationLogger(
"org.jellyfin.swiftfin.logger.file-rotation",
fileURL: logFileURL
)
fileRotationLogger.format = LogFormatter()
let consoleLogger = ConsoleLogger("org.jellyfin.swiftfin.logger.console")
@ -48,10 +52,18 @@ class LogManager {
}
class LogFormatter: LogFormattable {
func formatMessage(_ level: LogLevel, message: String, tag: String, function: String,
file: String, line: UInt, swiftLogInfo: [String: String],
label: String, date: Date, threadID: UInt64) -> String
{
func formatMessage(
_ level: LogLevel,
message: String,
tag: String,
function: String,
file: String,
line: UInt,
swiftLogInfo: [String: String],
label: String,
date: Date,
threadID: UInt64
) -> String {
let file = shortFileName(file).replacingOccurrences(of: ".swift", with: "")
return " [\(level.emoji) \(level)] \(file)#\(line):\(function) \(message)"
}

View File

@ -32,8 +32,10 @@ final class SessionManager {
private init() {
if let lastUserID = Defaults[.lastServerUserID],
let user = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredUser>(),
[Where<SwiftfinStore.Models.StoredUser>("id == %@", lastUserID)])
let user = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredUser>(),
[Where<SwiftfinStore.Models.StoredUser>("id == %@", lastUserID)]
)
{
guard let server = user.server,
@ -56,8 +58,10 @@ final class SessionManager {
// MARK: fetchUsers
func fetchUsers(for server: SwiftfinStore.State.Server) -> [SwiftfinStore.State.User] {
guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredServer>(),
Where<SwiftfinStore.Models.StoredServer>("id == %@", server.id))
guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredServer>(),
Where<SwiftfinStore.Models.StoredServer>("id == %@", server.id)
)
else { fatalError("No stored server associated with given state server?") }
return storedServer.users.map(\.state).sorted(by: { $0.username < $1.username })
}
@ -100,10 +104,13 @@ final class SessionManager {
newServer.users = []
// Check for existing server on device
if let existingServer = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredServer>(),
[Where<SwiftfinStore.Models.StoredServer>("id == %@",
newServer.id)])
{
if let existingServer = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredServer>(),
[Where<SwiftfinStore.Models.StoredServer>(
"id == %@",
newServer.id
)]
) {
throw SwiftfinStore.Error.existingServer(existingServer.state)
}
@ -126,9 +133,13 @@ final class SessionManager {
let transaction = SwiftfinStore.dataStack.beginUnsafe()
guard let existingServer = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredServer>(),
[Where<SwiftfinStore.Models.StoredServer>("id == %@",
server.id)])
guard let existingServer = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredServer>(),
[Where<SwiftfinStore.Models.StoredServer>(
"id == %@",
server.id
)]
)
else {
fatalError("No stored server associated with given state server?")
}
@ -155,9 +166,13 @@ final class SessionManager {
let transaction = SwiftfinStore.dataStack.beginUnsafe()
guard let existingServer = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredServer>(),
[Where<SwiftfinStore.Models.StoredServer>("id == %@",
server.id)])
guard let existingServer = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredServer>(),
[Where<SwiftfinStore.Models.StoredServer>(
"id == %@",
server.id
)]
)
else {
fatalError("No stored server associated with given state server?")
}
@ -183,9 +198,11 @@ final class SessionManager {
// MARK: loginUser publisher
// Logs in a user with an associated server, storing if successful
func loginUser(server: SwiftfinStore.State.Server, username: String,
password: String) -> AnyPublisher<SwiftfinStore.State.User, Error>
{
func loginUser(
server: SwiftfinStore.State.Server,
username: String,
password: String
) -> AnyPublisher<SwiftfinStore.State.User, Error> {
setAuthHeader(with: "")
JellyfinAPIAPI.basePath = server.currentURI
@ -206,10 +223,13 @@ final class SessionManager {
newUser.appleTVID = ""
// Check for existing user on device
if let existingUser = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredUser>(),
[Where<SwiftfinStore.Models.StoredUser>("id == %@",
newUser.id)])
{
if let existingUser = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredUser>(),
[Where<SwiftfinStore.Models.StoredUser>(
"id == %@",
newUser.id
)]
) {
throw SwiftfinStore.Error.existingUser(existingUser.state)
}
@ -217,11 +237,15 @@ final class SessionManager {
newAccessToken.value = accessToken
newUser.accessToken = newAccessToken
guard let userServer = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredServer>(),
guard let userServer = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredServer>(),
[
Where<SwiftfinStore.Models.StoredServer>("id == %@",
server.id),
])
Where<SwiftfinStore.Models.StoredServer>(
"id == %@",
server.id
),
]
)
else { fatalError("No stored server associated with given state server?") }
guard let editUserServer = transaction.edit(userServer) else { fatalError("Can't get proxy for existing object?") }
@ -284,8 +308,10 @@ final class SessionManager {
// MARK: delete user
func delete(user: SwiftfinStore.State.User) {
guard let storedUser = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredUser>(),
[Where<SwiftfinStore.Models.StoredUser>("id == %@", user.id)])
guard let storedUser = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredUser>(),
[Where<SwiftfinStore.Models.StoredUser>("id == %@", user.id)]
)
else { fatalError("No stored user for state user?") }
_delete(user: storedUser, transaction: nil)
}
@ -293,8 +319,10 @@ final class SessionManager {
// MARK: delete server
func delete(server: SwiftfinStore.State.Server) {
guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredServer>(),
[Where<SwiftfinStore.Models.StoredServer>("id == %@", server.id)])
guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredServer>(),
[Where<SwiftfinStore.Models.StoredServer>("id == %@", server.id)]
)
else { fatalError("No stored server for state server?") }
_delete(server: storedServer, transaction: nil)
}

View File

@ -27,9 +27,15 @@ enum SwiftfinStore {
let version: String
let userIDs: [String]
fileprivate init(uris: Set<String>, currentURI: String, name: String, id: String, os: String, version: String,
usersIDs: [String])
{
fileprivate init(
uris: Set<String>,
currentURI: String,
name: String,
id: String,
os: String,
version: String,
usersIDs: [String]
) {
self.uris = uris
self.currentURI = currentURI
self.name = name
@ -40,13 +46,15 @@ enum SwiftfinStore {
}
static var sample: Server {
Server(uris: ["https://www.notaurl.com", "http://www.maybeaurl.org"],
Server(
uris: ["https://www.notaurl.com", "http://www.maybeaurl.org"],
currentURI: "https://www.notaurl.com",
name: "Johnny's Tree",
id: "123abc",
os: "macOS",
version: "1.1.1",
usersIDs: ["1", "2"])
usersIDs: ["1", "2"]
)
}
}
@ -64,10 +72,12 @@ enum SwiftfinStore {
}
static var sample: User {
User(username: "JohnnyAppleseed",
User(
username: "JohnnyAppleseed",
id: "123abc",
serverID: "123abc",
accessToken: "open-sesame")
accessToken: "open-sesame"
)
}
}
}
@ -100,13 +110,15 @@ enum SwiftfinStore {
var users: Set<StoredUser>
var state: State.Server {
State.Server(uris: uris,
State.Server(
uris: uris,
currentURI: currentURI,
name: name,
id: id,
os: os,
version: version,
usersIDs: users.map(\.id))
usersIDs: users.map(\.id)
)
}
}
@ -130,10 +142,12 @@ enum SwiftfinStore {
var state: State.User {
guard let server = server else { fatalError("No server associated with user") }
guard let accessToken = accessToken else { fatalError("No access token associated with user") }
return State.User(username: username,
return State.User(
username: username,
id: id,
serverID: server.id,
accessToken: accessToken.value)
accessToken: accessToken.value
)
}
}
@ -157,7 +171,8 @@ enum SwiftfinStore {
// MARK: dataStack
static let dataStack: DataStack = {
let schema = CoreStoreSchema(modelVersion: "V1",
let schema = CoreStoreSchema(
modelVersion: "V1",
entities: [
Entity<SwiftfinStore.Models.StoredServer>("Server"),
Entity<SwiftfinStore.Models.StoredUser>("User"),
@ -182,11 +197,14 @@ enum SwiftfinStore {
0x9EDA_7328_21A1_5EA9,
0xB5A_FA53_1E41_CE8A,
],
])
]
)
let _dataStack = DataStack(schema)
try! _dataStack.addStorageAndWait(SQLiteStore(fileName: "Swiftfin.sqlite",
localStorageOptions: .recreateStoreOnModelMismatch))
try! _dataStack.addStorageAndWait(SQLiteStore(
fileName: "Swiftfin.sqlite",
localStorageOptions: .recreateStoreOnModelMismatch
))
return _dataStack
}()
}

View File

@ -27,9 +27,11 @@ extension Defaults.Keys {
static let inNetworkBandwidth = Key<Int>("InNetworkBandwidth", default: 40_000_000, suite: SwiftfinStore.Defaults.generalSuite)
static let outOfNetworkBandwidth = Key<Int>("OutOfNetworkBandwidth", default: 40_000_000, suite: SwiftfinStore.Defaults.generalSuite)
static let isAutoSelectSubtitles = Key<Bool>("isAutoSelectSubtitles", default: false, suite: SwiftfinStore.Defaults.generalSuite)
static let autoSelectSubtitlesLangCode = Key<String>("AutoSelectSubtitlesLangCode",
static let autoSelectSubtitlesLangCode = Key<String>(
"AutoSelectSubtitlesLangCode",
default: "Auto",
suite: SwiftfinStore.Defaults.generalSuite)
suite: SwiftfinStore.Defaults.generalSuite
)
static let autoSelectAudioLangCode = Key<String>("AutoSelectAudioLangCode", default: "Auto", suite: SwiftfinStore.Defaults.generalSuite)
// Customize settings
@ -40,21 +42,31 @@ extension Defaults.Keys {
// Video player / overlay settings
static let overlayType = Key<OverlayType>("overlayType", default: .normal, suite: SwiftfinStore.Defaults.generalSuite)
static let jumpGesturesEnabled = Key<Bool>("gesturesEnabled", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let systemControlGesturesEnabled = Key<Bool>("systemControlGesturesEnabled",
static let systemControlGesturesEnabled = Key<Bool>(
"systemControlGesturesEnabled",
default: true,
suite: SwiftfinStore.Defaults.generalSuite)
static let playerGesturesLockGestureEnabled = Key<Bool>("playerGesturesLockGestureEnabled",
suite: SwiftfinStore.Defaults.generalSuite
)
static let playerGesturesLockGestureEnabled = Key<Bool>(
"playerGesturesLockGestureEnabled",
default: true,
suite: SwiftfinStore.Defaults.generalSuite)
static let seekSlideGestureEnabled = Key<Bool>("seekSlideGestureEnabled",
suite: SwiftfinStore.Defaults.generalSuite
)
static let seekSlideGestureEnabled = Key<Bool>(
"seekSlideGestureEnabled",
default: true,
suite: SwiftfinStore.Defaults.generalSuite)
static let videoPlayerJumpForward = Key<VideoPlayerJumpLength>("videoPlayerJumpForward",
suite: SwiftfinStore.Defaults.generalSuite
)
static let videoPlayerJumpForward = Key<VideoPlayerJumpLength>(
"videoPlayerJumpForward",
default: .fifteen,
suite: SwiftfinStore.Defaults.generalSuite)
static let videoPlayerJumpBackward = Key<VideoPlayerJumpLength>("videoPlayerJumpBackward",
suite: SwiftfinStore.Defaults.generalSuite
)
static let videoPlayerJumpBackward = Key<VideoPlayerJumpLength>(
"videoPlayerJumpBackward",
default: .fifteen,
suite: SwiftfinStore.Defaults.generalSuite)
suite: SwiftfinStore.Defaults.generalSuite
)
static let autoplayEnabled = Key<Bool>("autoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let resumeOffset = Key<Bool>("resumeOffset", default: false, suite: SwiftfinStore.Defaults.generalSuite)
static let subtitleSize = Key<SubtitleSize>("subtitleSize", default: .regular, suite: SwiftfinStore.Defaults.generalSuite)
@ -69,19 +81,25 @@ extension Defaults.Keys {
static let shouldShowMissingEpisodes = Key<Bool>("shouldShowMissingEpisodes", default: true, suite: SwiftfinStore.Defaults.generalSuite)
// Should show video player items in overlay menu
static let shouldShowJumpButtonsInOverlayMenu = Key<Bool>("shouldShowJumpButtonsInMenu",
static let shouldShowJumpButtonsInOverlayMenu = Key<Bool>(
"shouldShowJumpButtonsInMenu",
default: true,
suite: SwiftfinStore.Defaults.generalSuite)
suite: SwiftfinStore.Defaults.generalSuite
)
static let shouldShowChaptersInfoInBottomOverlay = Key<Bool>("shouldShowChaptersInfoInBottomOverlay",
static let shouldShowChaptersInfoInBottomOverlay = Key<Bool>(
"shouldShowChaptersInfoInBottomOverlay",
default: true,
suite: SwiftfinStore.Defaults.generalSuite)
suite: SwiftfinStore.Defaults.generalSuite
)
// Experimental settings
enum Experimental {
static let syncSubtitleStateWithAdjacent = Key<Bool>("experimental.syncSubtitleState",
static let syncSubtitleStateWithAdjacent = Key<Bool>(
"experimental.syncSubtitleState",
default: false,
suite: SwiftfinStore.Defaults.generalSuite)
suite: SwiftfinStore.Defaults.generalSuite
)
static let forceDirectPlay = Key<Bool>("forceDirectPlay", default: false, suite: SwiftfinStore.Defaults.generalSuite)
static let nativePlayer = Key<Bool>("nativePlayer", default: false, suite: SwiftfinStore.Defaults.generalSuite)
static let liveTVAlphaEnabled = Key<Bool>("liveTVAlphaEnabled", default: false, suite: SwiftfinStore.Defaults.generalSuite)

View File

@ -74,9 +74,11 @@ final class ConnectToServerViewModel: ViewModel {
self.handleAPIRequestError(displayMessage: L10n.tooManyRedirects, completion: completion)
} else {
self
.connectToServer(uri: newURL.absoluteString
.connectToServer(
uri: newURL.absoluteString
.removeRegexMatches(pattern: "/web/index.html"),
redirectCount: redirectCount + 1)
redirectCount: redirectCount + 1
)
}
} else {
self.handleAPIRequestError(completion: completion)

View File

@ -27,9 +27,11 @@ extension EpisodesRowManager {
// Also retrieves the current season episodes if available
func retrieveSeasons() {
TvShowsAPI.getSeasons(seriesId: item.seriesId ?? "",
TvShowsAPI.getSeasons(
seriesId: item.seriesId ?? "",
userId: SessionManager.main.currentLogin.user.id,
isMissing: Defaults[.shouldShowMissingSeasons] ? nil : false)
isMissing: Defaults[.shouldShowMissingSeasons] ? nil : false
)
.sink { completion in
self.handleAPIRequestError(completion: completion)
} receiveValue: { response in
@ -52,11 +54,13 @@ extension EpisodesRowManager {
func retrieveEpisodesForSeason(_ season: BaseItemDto) {
guard let seasonID = season.id else { return }
TvShowsAPI.getEpisodes(seriesId: item.seriesId ?? "",
TvShowsAPI.getEpisodes(
seriesId: item.seriesId ?? "",
userId: SessionManager.main.currentLogin.user.id,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
seasonId: seasonID,
isMissing: Defaults[.shouldShowMissingEpisodes] ? nil : false)
isMissing: Defaults[.shouldShowMissingEpisodes] ? nil : false
)
.trackActivity(loading)
.sink { completion in
self.handleAPIRequestError(completion: completion)

View File

@ -125,7 +125,8 @@ final class HomeViewModel: ViewModel {
// MARK: Latest Added Items
private func refreshLatestAddedItems() {
UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id,
UserLibraryAPI.getLatestMedia(
userId: SessionManager.main.currentLogin.user.id,
fields: [
.primaryImageAspectRatio,
.seriesPrimaryImage,
@ -138,7 +139,8 @@ final class HomeViewModel: ViewModel {
includeItemTypes: [.movie, .series],
enableImageTypes: [.primary, .backdrop, .thumb],
enableUserData: true,
limit: 8)
limit: 8
)
.sink { completion in
switch completion {
case .finished: ()
@ -157,7 +159,8 @@ final class HomeViewModel: ViewModel {
// MARK: Resume Items
private func refreshResumeItems() {
ItemsAPI.getResumeItems(userId: SessionManager.main.currentLogin.user.id,
ItemsAPI.getResumeItems(
userId: SessionManager.main.currentLogin.user.id,
limit: 6,
fields: [
.primaryImageAspectRatio,
@ -168,7 +171,8 @@ final class HomeViewModel: ViewModel {
.people,
.chapters,
],
enableUserData: true)
enableUserData: true
)
.trackActivity(loading)
.sink(receiveCompletion: { completion in
switch completion {
@ -188,8 +192,10 @@ final class HomeViewModel: ViewModel {
func removeItemFromResume(_ item: BaseItemDto) {
guard let itemID = item.id, resumeItems.contains(where: { $0.id == itemID }) else { return }
PlaystateAPI.markUnplayedItem(userId: SessionManager.main.currentLogin.user.id,
itemId: item.id!)
PlaystateAPI.markUnplayedItem(
userId: SessionManager.main.currentLogin.user.id,
itemId: item.id!
)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { _ in
@ -202,7 +208,8 @@ final class HomeViewModel: ViewModel {
// MARK: Next Up Items
private func refreshNextUpItems() {
TvShowsAPI.getNextUp(userId: SessionManager.main.currentLogin.user.id,
TvShowsAPI.getNextUp(
userId: SessionManager.main.currentLogin.user.id,
limit: 6,
fields: [
.primaryImageAspectRatio,
@ -213,7 +220,8 @@ final class HomeViewModel: ViewModel {
.people,
.chapters,
],
enableUserData: true)
enableUserData: true
)
.trackActivity(loading)
.sink(receiveCompletion: { completion in
switch completion {

View File

@ -22,9 +22,11 @@ final class CollectionItemViewModel: ItemViewModel {
}
private func getCollectionItems() {
ItemsAPI.getItems(userId: SessionManager.main.currentLogin.user.id,
ItemsAPI.getItems(
userId: SessionManager.main.currentLogin.user.id,
parentId: item.id,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people])
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]
)
.trackActivity(loading)
.sink { [weak self] completion in
self?.handleAPIRequestError(completion: completion)

View File

@ -47,7 +47,8 @@ final class EpisodeItemViewModel: ItemViewModel, EpisodesRowManager {
}
override func updateItem() {
ItemsAPI.getItems(userId: SessionManager.main.currentLogin.user.id,
ItemsAPI.getItems(
userId: SessionManager.main.currentLogin.user.id,
limit: 1,
fields: [
.primaryImageAspectRatio,
@ -59,7 +60,8 @@ final class EpisodeItemViewModel: ItemViewModel, EpisodesRowManager {
.chapters,
],
enableUserData: true,
ids: [item.id ?? ""])
ids: [item.id ?? ""]
)
.sink { completion in
self.handleAPIRequestError(completion: completion)
} receiveValue: { response in

View File

@ -113,10 +113,12 @@ class ItemViewModel: ViewModel {
}
func getSimilarItems() {
LibraryAPI.getSimilarItems(itemId: item.id!,
LibraryAPI.getSimilarItems(
itemId: item.id!,
userId: SessionManager.main.currentLogin.user.id,
limit: 10,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people])
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]
)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
@ -128,8 +130,10 @@ class ItemViewModel: ViewModel {
func updateWatchState() {
if isWatched {
PlaystateAPI.markUnplayedItem(userId: SessionManager.main.currentLogin.user.id,
itemId: item.id!)
PlaystateAPI.markUnplayedItem(
userId: SessionManager.main.currentLogin.user.id,
itemId: item.id!
)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
@ -138,8 +142,10 @@ class ItemViewModel: ViewModel {
})
.store(in: &cancellables)
} else {
PlaystateAPI.markPlayedItem(userId: SessionManager.main.currentLogin.user.id,
itemId: item.id!)
PlaystateAPI.markPlayedItem(
userId: SessionManager.main.currentLogin.user.id,
itemId: item.id!
)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)

View File

@ -13,7 +13,8 @@ import JellyfinAPI
final class MovieItemViewModel: ItemViewModel {
override func updateItem() {
ItemsAPI.getItems(userId: SessionManager.main.currentLogin.user.id,
ItemsAPI.getItems(
userId: SessionManager.main.currentLogin.user.id,
limit: 1,
fields: [
.primaryImageAspectRatio,
@ -25,7 +26,8 @@ final class MovieItemViewModel: ItemViewModel {
.chapters,
],
enableUserData: true,
ids: [item.id ?? ""])
ids: [item.id ?? ""]
)
.sink { completion in
self.handleAPIRequestError(completion: completion)
} receiveValue: { response in

View File

@ -46,9 +46,12 @@ final class SeasonItemViewModel: ItemViewModel, EpisodesRowManager {
private func requestEpisodes() {
LogManager.log
.debug("Getting episodes in season \(item.id!) (\(item.name!)) of show \(item.seriesId!) (\(item.seriesName!))")
TvShowsAPI.getEpisodes(seriesId: item.seriesId ?? "", userId: SessionManager.main.currentLogin.user.id,
TvShowsAPI.getEpisodes(
seriesId: item.seriesId ?? "",
userId: SessionManager.main.currentLogin.user.id,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
seasonId: item.id ?? "")
seasonId: item.id ?? ""
)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
@ -64,9 +67,12 @@ final class SeasonItemViewModel: ItemViewModel, EpisodesRowManager {
private func setNextUpInSeason() {
TvShowsAPI.getNextUp(userId: SessionManager.main.currentLogin.user.id,
TvShowsAPI.getNextUp(
userId: SessionManager.main.currentLogin.user.id,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
seriesId: item.seriesId ?? "", enableUserData: true)
seriesId: item.seriesId ?? "",
enableUserData: true
)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
@ -109,8 +115,10 @@ final class SeasonItemViewModel: ItemViewModel, EpisodesRowManager {
private func getSeriesItem() {
guard let seriesID = item.seriesId else { return }
UserLibraryAPI.getItem(userId: SessionManager.main.currentLogin.user.id,
itemId: seriesID)
UserLibraryAPI.getItem(
userId: SessionManager.main.currentLogin.user.id,
itemId: seriesID
)
.trackActivity(loading)
.sink { [weak self] completion in
self?.handleAPIRequestError(completion: completion)

View File

@ -44,10 +44,12 @@ final class SeriesItemViewModel: ItemViewModel {
private func getNextUp() {
LogManager.log.debug("Getting next up for show \(self.item.id!) (\(self.item.name!))")
TvShowsAPI.getNextUp(userId: SessionManager.main.currentLogin.user.id,
TvShowsAPI.getNextUp(
userId: SessionManager.main.currentLogin.user.id,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
seriesId: self.item.id!,
enableUserData: true)
enableUserData: true
)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
@ -79,10 +81,13 @@ final class SeriesItemViewModel: ItemViewModel {
private func requestSeasons() {
LogManager.log.debug("Getting seasons of show \(self.item.id!) (\(self.item.name!))")
TvShowsAPI.getSeasons(seriesId: item.id ?? "", userId: SessionManager.main.currentLogin.user.id,
TvShowsAPI.getSeasons(
seriesId: item.id ?? "",
userId: SessionManager.main.currentLogin.user.id,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
isMissing: Defaults[.shouldShowMissingSeasons] ? nil : false,
enableUserData: true)
enableUserData: true
)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)

View File

@ -26,7 +26,8 @@ final class LatestMediaViewModel: ViewModel {
func requestLatestMedia() {
LogManager.log.debug("Requesting latest media for user id \(SessionManager.main.currentLogin.user.id)")
UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id,
UserLibraryAPI.getLatestMedia(
userId: SessionManager.main.currentLogin.user.id,
parentId: library.id ?? "",
fields: [
.primaryImageAspectRatio,
@ -37,7 +38,9 @@ final class LatestMediaViewModel: ViewModel {
.people,
],
includeItemTypes: [.series, .movie],
enableUserData: true, limit: 12)
enableUserData: true,
limit: 12
)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)

View File

@ -51,9 +51,11 @@ final class LibraryFilterViewModel: ViewModel {
modifiedFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name])
}
init(filters: LibraryFilters? = nil,
enabledFilterType: [FilterType] = [.tag, .genre, .sortBy, .sortOrder, .filter], parentId: String)
{
init(
filters: LibraryFilters? = nil,
enabledFilterType: [FilterType] = [.tag, .genre, .sortBy, .sortOrder, .filter],
parentId: String
) {
self.enabledFilterType = enabledFilterType
self.selectedSortBy = filters?.sortBy.first ?? .name
self.selectedSortOrder = filters?.sortOrder.first ?? .descending
@ -67,8 +69,10 @@ final class LibraryFilterViewModel: ViewModel {
}
func requestQueryFilters() {
FilterAPI.getQueryFilters(userId: SessionManager.main.currentLogin.user.id,
parentId: self.parentId)
FilterAPI.getQueryFilters(
userId: SessionManager.main.currentLogin.user.id,
parentId: self.parentId
)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)

View File

@ -90,7 +90,8 @@ final class LibrarySearchViewModel: ViewModel {
}
func requestSuggestions() {
ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id,
ItemsAPI.getItemsByUserId(
userId: SessionManager.main.currentLogin.user.id,
limit: 20,
recursive: true,
parentId: parentID,
@ -98,7 +99,8 @@ final class LibrarySearchViewModel: ViewModel {
sortBy: ["IsFavoriteOrLiked", "Random"],
imageTypeLimit: 0,
enableTotalRecordCount: false,
enableImages: false)
enableImages: false
)
.trackActivity(loading)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] completion in
@ -110,11 +112,19 @@ final class LibrarySearchViewModel: ViewModel {
}
func search(with query: String) {
ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, limit: 50, recursive: true, searchTerm: query,
sortOrder: [.ascending], parentId: parentID,
ItemsAPI.getItemsByUserId(
userId: SessionManager.main.currentLogin.user.id,
limit: 50,
recursive: true,
searchTerm: query,
sortOrder: [.ascending],
parentId: parentID,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
includeItemTypes: [.movie], sortBy: ["SortName"], enableUserData: true,
enableImages: true)
includeItemTypes: [.movie],
sortBy: ["SortName"],
enableUserData: true,
enableImages: true
)
.trackActivity(loading)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] completion in
@ -123,11 +133,19 @@ final class LibrarySearchViewModel: ViewModel {
self?.movieItems = response.items ?? []
})
.store(in: &cancellables)
ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, limit: 50, recursive: true, searchTerm: query,
sortOrder: [.ascending], parentId: parentID,
ItemsAPI.getItemsByUserId(
userId: SessionManager.main.currentLogin.user.id,
limit: 50,
recursive: true,
searchTerm: query,
sortOrder: [.ascending],
parentId: parentID,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
includeItemTypes: [.series], sortBy: ["SortName"], enableUserData: true,
enableImages: true)
includeItemTypes: [.series],
sortBy: ["SortName"],
enableUserData: true,
enableImages: true
)
.trackActivity(loading)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] completion in
@ -136,11 +154,19 @@ final class LibrarySearchViewModel: ViewModel {
self?.showItems = response.items ?? []
})
.store(in: &cancellables)
ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, limit: 50, recursive: true, searchTerm: query,
sortOrder: [.ascending], parentId: parentID,
ItemsAPI.getItemsByUserId(
userId: SessionManager.main.currentLogin.user.id,
limit: 50,
recursive: true,
searchTerm: query,
sortOrder: [.ascending],
parentId: parentID,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
includeItemTypes: [.episode], sortBy: ["SortName"], enableUserData: true,
enableImages: true)
includeItemTypes: [.episode],
sortBy: ["SortName"],
enableUserData: true,
enableImages: true
)
.trackActivity(loading)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] completion in

View File

@ -54,13 +54,14 @@ final class LibraryViewModel: ViewModel {
}
}
init(parentID: String? = nil,
init(
parentID: String? = nil,
person: BaseItemPerson? = nil,
genre: NameGuidPair? = nil,
studio: NameGuidPair? = nil,
filters: LibraryFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], sortBy: [.name]),
columns: Int = 7)
{
columns: Int = 7
) {
self.parentID = parentID
self.person = person
self.genre = genre
@ -106,7 +107,9 @@ final class LibraryViewModel: ViewModel {
includeItemTypes = [.movie, .series, .boxSet] + (Defaults[.showFlattenView] ? [] : [.folder])
}
ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, startIndex: currentPage * pageItemSize,
ItemsAPI.getItemsByUserId(
userId: SessionManager.main.currentLogin.user.id,
startIndex: currentPage * pageItemSize,
limit: pageItemSize,
recursive: queryRecursive,
searchTerm: nil,
@ -129,7 +132,8 @@ final class LibraryViewModel: ViewModel {
personIds: personIDs,
studioIds: studioIDs,
genreIds: genreIDs,
enableImages: true)
enableImages: true
)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
@ -174,8 +178,10 @@ final class LibraryViewModel: ViewModel {
rowCells.append(loadingCell)
}
calculatedRows.append(LibraryRow(section: i,
items: rowCells))
calculatedRows.append(LibraryRow(
section: i,
items: rowCells
))
}
return calculatedRows
}

View File

@ -77,12 +77,14 @@ final class LiveTVChannelsViewModel: ViewModel {
}
func getChannels() {
LiveTvAPI.getLiveTvChannels(userId: SessionManager.main.currentLogin.user.id,
LiveTvAPI.getLiveTvChannels(
userId: SessionManager.main.currentLogin.user.id,
startIndex: 0,
limit: 1000,
enableImageTypes: [.primary],
enableUserData: false,
enableFavoriteSorting: true)
enableFavoriteSorting: true
)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
@ -106,7 +108,8 @@ final class LiveTVChannelsViewModel: ViewModel {
let minEndDate = Date.now.addComponentsToDate(hours: -1)
let maxStartDate = minEndDate.addComponentsToDate(hours: 6)
let getProgramsRequest = GetProgramsRequest(channelIds: channelIds,
let getProgramsRequest = GetProgramsRequest(
channelIds: channelIds,
userId: SessionManager.main.currentLogin.user.id,
maxStartDate: maxStartDate,
minEndDate: minEndDate,
@ -115,7 +118,8 @@ final class LiveTVChannelsViewModel: ViewModel {
enableTotalRecordCount: false,
imageTypeLimit: 1,
enableImageTypes: [.primary],
enableUserData: false)
enableUserData: false
)
LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest)
.trackActivity(loading)

View File

@ -37,12 +37,14 @@ final class LiveTVProgramsViewModel: ViewModel {
}
private func getChannels() {
LiveTvAPI.getLiveTvChannels(userId: SessionManager.main.currentLogin.user.id,
LiveTvAPI.getLiveTvChannels(
userId: SessionManager.main.currentLogin.user.id,
startIndex: 0,
limit: 1000,
enableImageTypes: [.primary],
enableUserData: false,
enableFavoriteSorting: true)
enableFavoriteSorting: true
)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
@ -67,13 +69,15 @@ final class LiveTVProgramsViewModel: ViewModel {
}
private func getRecommendedPrograms() {
LiveTvAPI.getRecommendedPrograms(userId: SessionManager.main.currentLogin.user.id,
LiveTvAPI.getRecommendedPrograms(
userId: SessionManager.main.currentLogin.user.id,
limit: 9,
isAiring: true,
imageTypeLimit: 1,
enableImageTypes: [.primary, .thumb],
fields: [.channelInfo, .primaryImageAspectRatio],
enableTotalRecordCount: false)
enableTotalRecordCount: false
)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
@ -86,7 +90,8 @@ final class LiveTVProgramsViewModel: ViewModel {
}
private func getSeries() {
let getProgramsRequest = GetProgramsRequest(userId: SessionManager.main.currentLogin.user.id,
let getProgramsRequest = GetProgramsRequest(
userId: SessionManager.main.currentLogin.user.id,
hasAired: false,
isMovie: false,
isSeries: true,
@ -96,7 +101,8 @@ final class LiveTVProgramsViewModel: ViewModel {
limit: 9,
enableTotalRecordCount: false,
enableImageTypes: [.primary, .thumb],
fields: [.channelInfo, .primaryImageAspectRatio])
fields: [.channelInfo, .primaryImageAspectRatio]
)
LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest)
.trackActivity(loading)
@ -111,7 +117,8 @@ final class LiveTVProgramsViewModel: ViewModel {
}
private func getMovies() {
let getProgramsRequest = GetProgramsRequest(userId: SessionManager.main.currentLogin.user.id,
let getProgramsRequest = GetProgramsRequest(
userId: SessionManager.main.currentLogin.user.id,
hasAired: false,
isMovie: true,
isSeries: false,
@ -121,7 +128,8 @@ final class LiveTVProgramsViewModel: ViewModel {
limit: 9,
enableTotalRecordCount: false,
enableImageTypes: [.primary, .thumb],
fields: [.channelInfo, .primaryImageAspectRatio])
fields: [.channelInfo, .primaryImageAspectRatio]
)
LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest)
.trackActivity(loading)
@ -136,13 +144,15 @@ final class LiveTVProgramsViewModel: ViewModel {
}
private func getSports() {
let getProgramsRequest = GetProgramsRequest(userId: SessionManager.main.currentLogin.user.id,
let getProgramsRequest = GetProgramsRequest(
userId: SessionManager.main.currentLogin.user.id,
hasAired: false,
isSports: true,
limit: 9,
enableTotalRecordCount: false,
enableImageTypes: [.primary, .thumb],
fields: [.channelInfo, .primaryImageAspectRatio])
fields: [.channelInfo, .primaryImageAspectRatio]
)
LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest)
.trackActivity(loading)
@ -157,13 +167,15 @@ final class LiveTVProgramsViewModel: ViewModel {
}
private func getKids() {
let getProgramsRequest = GetProgramsRequest(userId: SessionManager.main.currentLogin.user.id,
let getProgramsRequest = GetProgramsRequest(
userId: SessionManager.main.currentLogin.user.id,
hasAired: false,
isKids: true,
limit: 9,
enableTotalRecordCount: false,
enableImageTypes: [.primary, .thumb],
fields: [.channelInfo, .primaryImageAspectRatio])
fields: [.channelInfo, .primaryImageAspectRatio]
)
LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest)
.trackActivity(loading)
@ -178,13 +190,15 @@ final class LiveTVProgramsViewModel: ViewModel {
}
private func getNews() {
let getProgramsRequest = GetProgramsRequest(userId: SessionManager.main.currentLogin.user.id,
let getProgramsRequest = GetProgramsRequest(
userId: SessionManager.main.currentLogin.user.id,
hasAired: false,
isNews: true,
limit: 9,
enableTotalRecordCount: false,
enableImageTypes: [.primary, .thumb],
fields: [.channelInfo, .primaryImageAspectRatio])
fields: [.channelInfo, .primaryImageAspectRatio]
)
LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest)
.trackActivity(loading)

View File

@ -84,8 +84,10 @@ final class MovieLibrariesViewModel: ViewModel {
rowCells.append(loadingCell)
}
calculatedRows.append(LibraryRow(section: i,
items: rowCells))
calculatedRows.append(LibraryRow(
section: i,
items: rowCells
))
}
return calculatedRows
}

View File

@ -84,8 +84,10 @@ final class TVLibrariesViewModel: ViewModel {
rowCells.append(loadingCell)
}
calculatedRows.append(LibraryRow(section: i,
items: rowCells))
calculatedRows.append(LibraryRow(
section: i,
items: rowCells
))
}
return calculatedRows
}

View File

@ -66,10 +66,12 @@ final class UserSignInViewModel: ViewModel {
}
func getProfileImageUrl(user: UserDto) -> URL? {
let urlString = ImageAPI.getUserImageWithRequestBuilder(userId: user.id ?? "--",
let urlString = ImageAPI.getUserImageWithRequestBuilder(
userId: user.id ?? "--",
imageType: .primary,
width: 200,
quality: 90).URLString
quality: 90
).URLString
return URL(string: urlString)
}

View File

@ -211,7 +211,8 @@ final class VideoPlayerViewModel: ViewModel {
// MARK: init
init(item: BaseItemDto,
init(
item: BaseItemDto,
title: String,
subtitle: String?,
directStreamURL: URL,
@ -232,8 +233,8 @@ final class VideoPlayerViewModel: ViewModel {
shouldShowAutoPlay: Bool,
container: String,
filename: String?,
versionName: String?)
{
versionName: String?
) {
self.item = item
self.title = title
self.subtitle = subtitle
@ -334,11 +335,13 @@ extension VideoPlayerViewModel {
func getAdjacentEpisodes() {
guard let seriesID = item.seriesId, item.itemType == .episode else { return }
TvShowsAPI.getEpisodes(seriesId: seriesID,
TvShowsAPI.getEpisodes(
seriesId: seriesID,
userId: SessionManager.main.currentLogin.user.id,
fields: [.chapters],
adjacentTo: item.id,
limit: 3)
limit: 3
)
.sink(receiveCompletion: { completion in
self.handleAPIRequestError(completion: completion)
}, receiveValue: { response in
@ -459,11 +462,13 @@ extension VideoPlayerViewModel {
extension VideoPlayerViewModel {
private func sendNewProgressReportWithTimer() {
progressReportTimer?.invalidate()
progressReportTimer = Timer.scheduledTimer(timeInterval: 0.7,
progressReportTimer = Timer.scheduledTimer(
timeInterval: 0.7,
target: self,
selector: #selector(_sendProgressReport),
userInfo: nil,
repeats: false)
repeats: false
)
}
}
@ -477,7 +482,8 @@ extension VideoPlayerViewModel {
let subtitleStreamIndex = subtitlesEnabled ? selectedSubtitleStreamIndex : nil
let reportPlaybackStartRequest = ReportPlaybackStartRequest(canSeek: true,
let reportPlaybackStartRequest = ReportPlaybackStartRequest(
canSeek: true,
itemId: item.id,
sessionId: response.playSessionId,
mediaSourceId: item.id,
@ -495,7 +501,8 @@ extension VideoPlayerViewModel {
playSessionId: response.playSessionId,
repeatMode: .repeatNone,
nowPlayingQueue: nil,
playlistItemId: "playlistItem0")
playlistItemId: "playlistItem0"
)
PlaystateAPI.reportPlaybackStart(reportPlaybackStartRequest: reportPlaybackStartRequest)
.sink { completion in
@ -511,7 +518,8 @@ extension VideoPlayerViewModel {
func sendPauseReport(paused: Bool) {
let subtitleStreamIndex = subtitlesEnabled ? selectedSubtitleStreamIndex : nil
let reportPlaybackStartRequest = ReportPlaybackStartRequest(canSeek: true,
let reportPlaybackStartRequest = ReportPlaybackStartRequest(
canSeek: true,
itemId: item.id,
sessionId: response.playSessionId,
mediaSourceId: item.id,
@ -529,7 +537,8 @@ extension VideoPlayerViewModel {
playSessionId: response.playSessionId,
repeatMode: .repeatNone,
nowPlayingQueue: nil,
playlistItemId: "playlistItem0")
playlistItemId: "playlistItem0"
)
PlaystateAPI.reportPlaybackStart(reportPlaybackStartRequest: reportPlaybackStartRequest)
.sink { completion in
@ -545,7 +554,8 @@ extension VideoPlayerViewModel {
func sendProgressReport() {
let subtitleStreamIndex = subtitlesEnabled ? selectedSubtitleStreamIndex : nil
let progressInfo = ReportPlaybackProgressRequest(canSeek: true,
let progressInfo = ReportPlaybackProgressRequest(
canSeek: true,
itemId: item.id,
sessionId: response.playSessionId,
mediaSourceId: item.id,
@ -563,7 +573,8 @@ extension VideoPlayerViewModel {
playSessionId: response.playSessionId,
repeatMode: .repeatNone,
nowPlayingQueue: nil,
playlistItemId: "playlistItem0")
playlistItemId: "playlistItem0"
)
lastProgressReport = progressInfo
@ -588,7 +599,8 @@ extension VideoPlayerViewModel {
// MARK: sendStopReport
func sendStopReport() {
let reportPlaybackStoppedRequest = ReportPlaybackStoppedRequest(itemId: item.id,
let reportPlaybackStoppedRequest = ReportPlaybackStoppedRequest(
itemId: item.id,
sessionId: response.playSessionId,
mediaSourceId: item.id,
positionTicks: currentSecondTicks,
@ -597,7 +609,8 @@ extension VideoPlayerViewModel {
failed: nil,
nextMediaType: nil,
playlistItemId: "playlistItem0",
nowPlayingQueue: nil)
nowPlayingQueue: nil
)
PlaystateAPI.reportPlaybackStopped(reportPlaybackStoppedRequest: reportPlaybackStoppedRequest)
.sink { completion in

View File

@ -40,7 +40,9 @@ class ViewModel: ObservableObject {
networkError = .URLError(response: errorResponse, displayMessage: displayMessage)
// Use the errorResponse description for debugging, rather than the user-facing friendly description which may not be implemented
LogManager.log
.error("Request failed: URL request failed with error \(networkError.errorMessage.code): \(errorResponse.localizedDescription)")
.error(
"Request failed: URL request failed with error \(networkError.errorMessage.code): \(errorResponse.localizedDescription)"
)
case .error(-2, _, _, _):
networkError = .HTTPURLError(response: errorResponse, displayMessage: displayMessage)
LogManager.log
@ -49,23 +51,29 @@ class ViewModel: ObservableObject {
networkError = .JellyfinError(response: errorResponse, displayMessage: displayMessage)
// Able to use user-facing friendly description here since just HTTP status codes
LogManager.log
.error("Request failed: \(networkError.errorMessage.code) - \(networkError.errorMessage.title): \(networkError.errorMessage.message)\n\(error.localizedDescription)")
.error(
"Request failed: \(networkError.errorMessage.code) - \(networkError.errorMessage.title): \(networkError.errorMessage.message)\n\(error.localizedDescription)"
)
}
self.errorMessage = networkError.errorMessage
case is SwiftfinStore.Error:
let swiftfinError = error as! SwiftfinStore.Error
let errorMessage = ErrorMessage(code: ErrorMessage.noShowErrorCode,
let errorMessage = ErrorMessage(
code: ErrorMessage.noShowErrorCode,
title: swiftfinError.title,
message: swiftfinError.errorDescription ?? "")
message: swiftfinError.errorDescription ?? ""
)
self.errorMessage = errorMessage
LogManager.log.error("Request failed: \(swiftfinError.errorDescription ?? "")")
default:
let genericErrorMessage = ErrorMessage(code: ErrorMessage.noShowErrorCode,
let genericErrorMessage = ErrorMessage(
code: ErrorMessage.noShowErrorCode,
title: "Generic Error",
message: error.localizedDescription)
message: error.localizedDescription
)
self.errorMessage = genericErrorMessage
LogManager.log.error("Request failed: Generic error - \(error.localizedDescription)")
}

View File

@ -65,9 +65,11 @@ struct MultiSelector<Selectable: Hashable>: View {
}
private func multiSelectionView() -> some View {
MultiSelectionView(options: options,
MultiSelectionView(
options: options,
optionToString: optionToString,
label: self.label,
selected: selected)
selected: selected
)
}
}

View File

@ -16,12 +16,13 @@ struct ParallaxHeaderScrollView<Header: View, StaticOverlayView: View, Content:
var headerHeight: CGFloat
var content: () -> Content
init(header: Header,
init(
header: Header,
staticOverlayView: StaticOverlayView,
overlayAlignment: Alignment = .center,
headerHeight: CGFloat,
content: @escaping () -> Content)
{
content: @escaping () -> Content
) {
self.header = header
self.staticOverlayView = staticOverlayView
self.overlayAlignment = overlayAlignment

View File

@ -67,9 +67,11 @@ struct SearchablePicker<Selectable: Hashable>: View {
}
private func searchablePickerView() -> some View {
SearchablePickerView(options: options,
SearchablePickerView(
options: options,
optionToString: optionToString,
label: label,
selected: $selected)
selected: $selected
)
}
}

View File

@ -21,8 +21,10 @@ struct EpisodeRowCard: View {
Button {
itemRouter.route(to: \.item, episode)
} label: {
ImageView(episode.getBackdropImage(maxWidth: 550),
blurHash: episode.getBackdropImageBlurHash())
ImageView(
episode.getBackdropImage(maxWidth: 550),
blurHash: episode.getBackdropImageBlurHash()
)
.mask(Rectangle().frame(width: 550, height: 308))
.frame(width: 550, height: 308)
}

View File

@ -37,9 +37,11 @@ struct CinematicNextUpCardView: View {
.frame(width: 350, height: 210)
}
LinearGradient(colors: [.clear, .black],
LinearGradient(
colors: [.clear, .black],
startPoint: .top,
endPoint: .bottom)
endPoint: .bottom
)
.frame(height: 105)
.ignoresSafeArea()

View File

@ -38,9 +38,11 @@ struct CinematicResumeCardView: View {
.frame(width: 350, height: 210)
}
LinearGradient(colors: [.clear, .black],
LinearGradient(
colors: [.clear, .black],
startPoint: .top,
endPoint: .bottom)
endPoint: .bottom
)
.frame(height: 105)
.ignoresSafeArea()

View File

@ -56,13 +56,15 @@ struct HomeCinematicView: View {
CinematicBackgroundView(viewModel: backgroundViewModel)
.frame(height: UIScreen.main.bounds.height - 10)
LinearGradient(stops: [
LinearGradient(
stops: [
.init(color: .clear, location: 0.5),
.init(color: .black.opacity(0.6), location: 0.7),
.init(color: .black, location: 1),
],
startPoint: .top,
endPoint: .bottom)
endPoint: .bottom
)
.ignoresSafeArea()
VStack(alignment: .leading, spacing: 0) {

View File

@ -41,8 +41,13 @@ class UICinematicBackgroundView: UIView {
selectDelayTimer?.invalidate()
selectDelayTimer = Timer.scheduledTimer(timeInterval: 0.5, target: self, selector: #selector(delayTimerTimed), userInfo: imageView,
repeats: false)
selectDelayTimer = Timer.scheduledTimer(
timeInterval: 0.5,
target: self,
selector: #selector(delayTimerTimed),
userInfo: imageView,
repeats: false
)
}
@objc

View File

@ -23,11 +23,19 @@ struct CutOffShadow: Shape {
path.move(to: tl)
path.addLine(to: tr)
path.addLine(to: brs)
path.addRelativeArc(center: brc, radius: 6,
startAngle: Angle.degrees(0), delta: Angle.degrees(90))
path.addRelativeArc(
center: brc,
radius: 6,
startAngle: Angle.degrees(0),
delta: Angle.degrees(90)
)
path.addLine(to: bls)
path.addRelativeArc(center: blc, radius: 6,
startAngle: Angle.degrees(90), delta: Angle.degrees(90))
path.addRelativeArc(
center: blc,
radius: 6,
startAngle: Angle.degrees(90),
delta: Angle.degrees(90)
)
return path
}
@ -46,13 +54,16 @@ struct LandscapeItemElement: View {
var body: some View {
VStack {
ImageView(item.type == .episode && !(inSeasonView ?? false) ? item.getSeriesBackdropImage(maxWidth: 445) : item
ImageView(
item.type == .episode && !(inSeasonView ?? false) ? item.getSeriesBackdropImage(maxWidth: 445) : item
.getBackdropImage(maxWidth: 445),
blurHash: item.type == .episode ? item.getSeriesBackdropImageBlurHash() : item.getBackdropImageBlurHash())
blurHash: item.type == .episode ? item.getSeriesBackdropImageBlurHash() : item.getBackdropImageBlurHash()
)
.frame(width: 445, height: 250)
.cornerRadius(10)
.ignoresSafeArea()
.overlay(ZStack {
.overlay(
ZStack {
if item.userData?.played ?? false {
Image(systemName: "circle.fill")
.foregroundColor(.white)
@ -60,13 +71,17 @@ struct LandscapeItemElement: View {
.foregroundColor(Color(.systemBlue))
}
}.padding(2)
.opacity(1), alignment: .topTrailing).opacity(1)
.opacity(1),
alignment: .topTrailing
).opacity(1)
.overlay(ZStack(alignment: .leading) {
if focused && item.userData?.playedPercentage != nil {
Rectangle()
.fill(LinearGradient(gradient: Gradient(colors: [.black, .clear]),
.fill(LinearGradient(
gradient: Gradient(colors: [.black, .clear]),
startPoint: .bottom,
endPoint: .top))
endPoint: .top
))
.frame(width: 445, height: 90)
.mask(CutOffShadow())
VStack(alignment: .leading) {

View File

@ -41,8 +41,11 @@ struct MediaPlayButtonRowView: View {
Button {
viewModel.updateFavoriteState()
} label: {
MediaViewActionButton(icon: "heart.fill", scrollView: $wrappedScrollView,
iconColor: viewModel.isFavorited ? .red : .white)
MediaViewActionButton(
icon: "heart.fill",
scrollView: $wrappedScrollView,
iconColor: viewModel.isFavorited ? .red : .white
)
}
Text(viewModel.isFavorited ? "Unfavorite" : "Favorite")
.font(.caption)

View File

@ -21,13 +21,16 @@ struct PortraitItemElement: View {
var body: some View {
VStack {
ImageView(item.type == .episode ? item.getSeriesPrimaryImage(maxWidth: 200) : item.getPrimaryImage(maxWidth: 200),
blurHash: item.type == .episode ? item.getSeriesPrimaryImageBlurHash() : item.getPrimaryImageBlurHash())
ImageView(
item.type == .episode ? item.getSeriesPrimaryImage(maxWidth: 200) : item.getPrimaryImage(maxWidth: 200),
blurHash: item.type == .episode ? item.getSeriesPrimaryImageBlurHash() : item.getPrimaryImageBlurHash()
)
.frame(width: 200, height: 300)
.cornerRadius(10)
.shadow(radius: focused ? 10.0 : 0)
.shadow(radius: focused ? 10.0 : 0)
.overlay(ZStack {
.overlay(
ZStack {
if item.userData?.isFavorite ?? false {
Image(systemName: "circle.fill")
.foregroundColor(.white)
@ -38,8 +41,11 @@ struct PortraitItemElement: View {
}
}
.padding(2)
.opacity(1), alignment: .bottomLeading)
.overlay(ZStack {
.opacity(1),
alignment: .bottomLeading
)
.overlay(
ZStack {
if item.userData?.played ?? false {
Image(systemName: "circle.fill")
.foregroundColor(.white)
@ -55,7 +61,9 @@ struct PortraitItemElement: View {
}
}
}.padding(2)
.opacity(1), alignment: .topTrailing).opacity(1)
.opacity(1),
alignment: .topTrailing
).opacity(1)
Text(item.title)
.frame(width: 200, height: 30, alignment: .center)
if item.type == .movie || item.type == .series {

View File

@ -19,11 +19,12 @@ struct PortraitItemsRowView: View {
let showItemTitles: Bool
let selectedAction: (BaseItemDto) -> Void
init(rowTitle: String,
init(
rowTitle: String,
items: [BaseItemDto],
showItemTitles: Bool = true,
selectedAction: @escaping (BaseItemDto) -> Void)
{
selectedAction: @escaping (BaseItemDto) -> Void
) {
self.rowTitle = rowTitle
self.items = items
self.showItemTitles = showItemTitles

View File

@ -20,7 +20,11 @@ struct PublicUserButton: View {
var body: some View {
VStack {
if publicUser.primaryImageTag != nil {
ImageView(URL(string: "\(SessionManager.main.currentLogin.server.currentURI)/Users/\(publicUser.id ?? "")/Images/Primary?width=500&quality=80&tag=\(publicUser.primaryImageTag!)")!)
ImageView(
URL(
string: "\(SessionManager.main.currentLogin.server.currentURI)/Users/\(publicUser.id ?? "")/Images/Primary?width=500&quality=80&tag=\(publicUser.primaryImageTag!)"
)!
)
.frame(width: 250, height: 250)
.cornerRadius(125.0)
} else {

View File

@ -39,15 +39,15 @@ struct BasicAppSettingsView: View {
}
// TODO: Implement once design is theme appearance friendly
// Section {
// Picker(L10n.appearance, selection: $appAppearance) {
// ForEach(self.viewModel.appearances, id: \.self) { appearance in
// Text(appearance.localizedName).tag(appearance.rawValue)
// }
// }
// } header: {
// L10n.accessibility.text
// }
// Section {
// Picker(L10n.appearance, selection: $appAppearance) {
// ForEach(self.viewModel.appearances, id: \.self) { appearance in
// Text(appearance.localizedName).tag(appearance.rawValue)
// }
// }
// } header: {
// L10n.accessibility.text
// }
Button {
resetTapped = true

View File

@ -76,9 +76,11 @@ struct ConnectToServerView: View {
.headerProminence(.increased)
}
.alert(item: $viewModel.errorMessage) { _ in
Alert(title: Text(viewModel.alertTitle),
Alert(
title: Text(viewModel.alertTitle),
message: Text(viewModel.errorMessage?.message ?? L10n.unknownError),
dismissButton: .cancel())
dismissButton: .cancel()
)
}
.navigationTitle(L10n.connect)
}

View File

@ -45,9 +45,11 @@ struct ContinueWatchingCard: View {
}
}
.background {
LinearGradient(colors: [.clear, .black.opacity(0.5), .black.opacity(0.7)],
LinearGradient(
colors: [.clear, .black.opacity(0.5), .black.opacity(0.7)],
startPoint: .top,
endPoint: .bottom)
endPoint: .bottom
)
.ignoresSafeArea()
}
}

View File

@ -32,26 +32,32 @@ struct HomeView: View {
LazyVStack(alignment: .leading) {
if viewModel.resumeItems.isEmpty {
HomeCinematicView(viewModel: viewModel,
HomeCinematicView(
viewModel: viewModel,
items: viewModel.latestAddedItems.map { .init(item: $0, type: .plain) },
forcedItemSubtitle: L10n.recentlyAdded)
forcedItemSubtitle: L10n.recentlyAdded
)
if !viewModel.nextUpItems.isEmpty {
NextUpView(items: viewModel.nextUpItems)
.focusSection()
}
} else {
HomeCinematicView(viewModel: viewModel,
items: viewModel.resumeItems.map { .init(item: $0, type: .resume) })
HomeCinematicView(
viewModel: viewModel,
items: viewModel.resumeItems.map { .init(item: $0, type: .resume) }
)
if !viewModel.nextUpItems.isEmpty {
NextUpView(items: viewModel.nextUpItems)
.focusSection()
}
PortraitItemsRowView(rowTitle: L10n.recentlyAdded,
PortraitItemsRowView(
rowTitle: L10n.recentlyAdded,
items: viewModel.latestAddedItems,
showItemTitles: showPosterLabels) { item in
showItemTitles: showPosterLabels
) { item in
homeRouter.route(to: \.modalItem, item)
}
}

View File

@ -24,17 +24,21 @@ struct CinematicCollectionItemView: View {
var body: some View {
ZStack {
ImageView(viewModel.item.getBackdropImage(maxWidth: 1920),
blurHash: viewModel.item.getBackdropImageBlurHash())
ImageView(
viewModel.item.getBackdropImage(maxWidth: 1920),
blurHash: viewModel.item.getBackdropImageBlurHash()
)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 0) {
CinematicItemViewTopRow(viewModel: viewModel,
CinematicItemViewTopRow(
viewModel: viewModel,
wrappedScrollView: wrappedScrollView,
title: viewModel.item.name ?? "",
showDetails: false)
showDetails: false
)
.focusSection()
.frame(height: UIScreen.main.bounds.height - 10)
@ -46,15 +50,19 @@ struct CinematicCollectionItemView: View {
CinematicItemAboutView(viewModel: viewModel)
PortraitItemsRowView(rowTitle: L10n.items,
items: viewModel.collectionItems) { item in
PortraitItemsRowView(
rowTitle: L10n.items,
items: viewModel.collectionItems
) { item in
itemRouter.route(to: \.item, item)
}
if !viewModel.similarItems.isEmpty {
PortraitItemsRowView(rowTitle: L10n.recommended,
PortraitItemsRowView(
rowTitle: L10n.recommended,
items: viewModel.similarItems,
showItemTitles: showPosterLabels) { item in
showItemTitles: showPosterLabels
) { item in
itemRouter.route(to: \.item, item)
}
}

View File

@ -32,18 +32,22 @@ struct CinematicEpisodeItemView: View {
var body: some View {
ZStack {
ImageView(viewModel.item.getBackdropImage(maxWidth: 1920),
blurHash: viewModel.item.getBackdropImageBlurHash())
ImageView(
viewModel.item.getBackdropImage(maxWidth: 1920),
blurHash: viewModel.item.getBackdropImageBlurHash()
)
.frame(height: UIScreen.main.bounds.height - 10)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 0) {
CinematicItemViewTopRow(viewModel: viewModel,
CinematicItemViewTopRow(
viewModel: viewModel,
wrappedScrollView: wrappedScrollView,
title: viewModel.item.name ?? "",
subtitle: generateSubtitle())
subtitle: generateSubtitle()
)
.focusSection()
.frame(height: UIScreen.main.bounds.height - 10)
@ -60,16 +64,20 @@ struct CinematicEpisodeItemView: View {
.focusSection()
if let seriesItem = viewModel.series {
PortraitItemsRowView(rowTitle: L10n.series,
items: [seriesItem]) { seriesItem in
PortraitItemsRowView(
rowTitle: L10n.series,
items: [seriesItem]
) { seriesItem in
itemRouter.route(to: \.item, seriesItem)
}
}
if !viewModel.similarItems.isEmpty {
PortraitItemsRowView(rowTitle: L10n.recommended,
PortraitItemsRowView(
rowTitle: L10n.recommended,
items: viewModel.similarItems,
showItemTitles: showPosterLabels) { item in
showItemTitles: showPosterLabels
) { item in
itemRouter.route(to: \.item, item)
}
}

View File

@ -28,12 +28,13 @@ struct CinematicItemViewTopRow: View {
private var playButtonText: String = ""
let showDetails: Bool
init(viewModel: ItemViewModel,
init(
viewModel: ItemViewModel,
wrappedScrollView: UIScrollView? = nil,
title: String,
subtitle: String? = nil,
showDetails: Bool = true)
{
showDetails: Bool = true
) {
self.viewModel = viewModel
self.wrappedScrollView = wrappedScrollView
self.title = title
@ -43,9 +44,11 @@ struct CinematicItemViewTopRow: View {
var body: some View {
ZStack(alignment: .bottom) {
LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]),
LinearGradient(
gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]),
startPoint: .top,
endPoint: .bottom)
endPoint: .bottom
)
.ignoresSafeArea()
.frame(height: 210)
@ -138,8 +141,10 @@ struct CinematicItemViewTopRow: View {
.fontWeight(.semibold)
.lineLimit(1)
.padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4))
.overlay(RoundedRectangle(cornerRadius: 2)
.stroke(Color.secondary, lineWidth: 1))
.overlay(
RoundedRectangle(cornerRadius: 2)
.stroke(Color.secondary, lineWidth: 1)
)
}
if viewModel.item.unaired {

View File

@ -24,17 +24,21 @@ struct CinematicMovieItemView: View {
var body: some View {
ZStack {
ImageView(viewModel.item.getBackdropImage(maxWidth: 1920),
blurHash: viewModel.item.getBackdropImageBlurHash())
ImageView(
viewModel.item.getBackdropImage(maxWidth: 1920),
blurHash: viewModel.item.getBackdropImageBlurHash()
)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 0) {
CinematicItemViewTopRow(viewModel: viewModel,
CinematicItemViewTopRow(
viewModel: viewModel,
wrappedScrollView: wrappedScrollView,
title: viewModel.item.name ?? "",
subtitle: nil)
subtitle: nil
)
.focusSection()
.frame(height: UIScreen.main.bounds.height - 10)
@ -47,9 +51,11 @@ struct CinematicMovieItemView: View {
CinematicItemAboutView(viewModel: viewModel)
if !viewModel.similarItems.isEmpty {
PortraitItemsRowView(rowTitle: L10n.recommended,
PortraitItemsRowView(
rowTitle: L10n.recommended,
items: viewModel.similarItems,
showItemTitles: showPosterLabels) { item in
showItemTitles: showPosterLabels
) { item in
itemRouter.route(to: \.item, item)
}
}

View File

@ -30,16 +30,20 @@ struct CinematicSeasonItemView: View {
VStack(spacing: 0) {
if let seriesItem = viewModel.seriesItem {
CinematicItemViewTopRow(viewModel: viewModel,
CinematicItemViewTopRow(
viewModel: viewModel,
wrappedScrollView: wrappedScrollView,
title: viewModel.item.name ?? "",
subtitle: seriesItem.name)
subtitle: seriesItem.name
)
.focusSection()
.frame(height: UIScreen.main.bounds.height - 10)
} else {
CinematicItemViewTopRow(viewModel: viewModel,
CinematicItemViewTopRow(
viewModel: viewModel,
wrappedScrollView: wrappedScrollView,
title: viewModel.item.name ?? "")
title: viewModel.item.name ?? ""
)
.focusSection()
.frame(height: UIScreen.main.bounds.height - 10)
}
@ -57,16 +61,20 @@ struct CinematicSeasonItemView: View {
.focusSection()
if let seriesItem = viewModel.seriesItem {
PortraitItemsRowView(rowTitle: L10n.series,
items: [seriesItem]) { seriesItem in
PortraitItemsRowView(
rowTitle: L10n.series,
items: [seriesItem]
) { seriesItem in
itemRouter.route(to: \.item, seriesItem)
}
}
if !viewModel.similarItems.isEmpty {
PortraitItemsRowView(rowTitle: L10n.recommended,
PortraitItemsRowView(
rowTitle: L10n.recommended,
items: viewModel.similarItems,
showItemTitles: showPosterLabels) { item in
showItemTitles: showPosterLabels
) { item in
itemRouter.route(to: \.item, item)
}
}

View File

@ -29,10 +29,12 @@ struct CinematicSeriesItemView: View {
ScrollView {
VStack(spacing: 0) {
CinematicItemViewTopRow(viewModel: viewModel,
CinematicItemViewTopRow(
viewModel: viewModel,
wrappedScrollView: wrappedScrollView,
title: viewModel.item.name ?? "",
subtitle: nil)
subtitle: nil
)
.focusSection()
.frame(height: UIScreen.main.bounds.height - 10)
@ -45,16 +47,20 @@ struct CinematicSeriesItemView: View {
CinematicItemAboutView(viewModel: viewModel)
PortraitItemsRowView(rowTitle: L10n.seasons,
PortraitItemsRowView(
rowTitle: L10n.seasons,
items: viewModel.seasons,
showItemTitles: showPosterLabels) { season in
showItemTitles: showPosterLabels
) { season in
itemRouter.route(to: \.item, season)
}
if !viewModel.similarItems.isEmpty {
PortraitItemsRowView(rowTitle: L10n.recommended,
PortraitItemsRowView(
rowTitle: L10n.recommended,
items: viewModel.similarItems,
showItemTitles: showPosterLabels) { item in
showItemTitles: showPosterLabels
) { item in
itemRouter.route(to: \.item, item)
}
}

View File

@ -76,8 +76,10 @@ struct EpisodeItemView: View {
.foregroundColor(.secondary)
.lineLimit(1)
.padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4))
.overlay(RoundedRectangle(cornerRadius: 2)
.stroke(Color.secondary, lineWidth: 1))
.overlay(
RoundedRectangle(cornerRadius: 2)
.stroke(Color.secondary, lineWidth: 1)
)
}
Spacer()
}.padding(.top, -15)

View File

@ -78,8 +78,10 @@ struct MovieItemView: View {
.foregroundColor(.secondary)
.lineLimit(1)
.padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4))
.overlay(RoundedRectangle(cornerRadius: 2)
.stroke(Color.secondary, lineWidth: 1))
.overlay(
RoundedRectangle(cornerRadius: 2)
.stroke(Color.secondary, lineWidth: 1)
)
}
}

View File

@ -47,8 +47,10 @@ struct SeasonItemView: View {
.foregroundColor(.secondary)
.lineLimit(1)
.padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4))
.overlay(RoundedRectangle(cornerRadius: 2)
.stroke(Color.secondary, lineWidth: 1))
.overlay(
RoundedRectangle(cornerRadius: 2)
.stroke(Color.secondary, lineWidth: 1)
)
}
if viewModel.item.communityRating != nil {
HStack {
@ -81,8 +83,11 @@ struct SeasonItemView: View {
Button {
viewModel.updateFavoriteState()
} label: {
MediaViewActionButton(icon: "heart.fill", scrollView: $wrappedScrollView,
iconColor: viewModel.isFavorited ? .red : .white)
MediaViewActionButton(
icon: "heart.fill",
scrollView: $wrappedScrollView,
iconColor: viewModel.isFavorited ? .red : .white
)
}.prefersDefaultFocus(in: namespace)
Text(viewModel.isFavorited ? "Unfavorite" : "Favorite")
.font(.caption)
@ -92,8 +97,11 @@ struct SeasonItemView: View {
Button {
viewModel.updateWatchState()
} label: {
MediaViewActionButton(icon: "eye.fill", scrollView: $wrappedScrollView,
iconColor: viewModel.isWatched ? .red : .white)
MediaViewActionButton(
icon: "eye.fill",
scrollView: $wrappedScrollView,
iconColor: viewModel.isWatched ? .red : .white
)
}
Text(viewModel.isWatched ? "Unwatch" : "Mark Watched")
.font(.caption)

View File

@ -69,8 +69,10 @@ struct SeriesItemView: View {
.foregroundColor(.secondary)
.lineLimit(1)
.padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4))
.overlay(RoundedRectangle(cornerRadius: 2)
.stroke(Color.secondary, lineWidth: 1))
.overlay(
RoundedRectangle(cornerRadius: 2)
.stroke(Color.secondary, lineWidth: 1)
)
}
if viewModel.item.communityRating != nil {
HStack {

View File

@ -49,10 +49,17 @@ struct LatestMediaView: View {
}
Button {
homeRouter.route(to: \.library, (viewModel: .init(parentID: viewModel.library.id!,
filters: LibraryFilters(filters: [], sortOrder: [.descending],
sortBy: [.dateAdded])),
title: viewModel.library.name ?? ""))
homeRouter.route(to: \.library, (
viewModel: .init(
parentID: viewModel.library.id!,
filters: LibraryFilters(
filters: [],
sortOrder: [.descending],
sortBy: [.dateAdded]
)
),
title: viewModel.library.name ?? ""
))
} label: {
ZStack {
Color(UIColor.darkGray)

View File

@ -35,22 +35,28 @@ struct LibraryFilterView: View {
} else {
Form {
if viewModel.enabledFilterType.contains(.genre) {
MultiSelector(label: L10n.genres,
MultiSelector(
label: L10n.genres,
options: viewModel.possibleGenres,
optionToString: { $0.name ?? "" },
selected: $viewModel.modifiedFilters.withGenres)
selected: $viewModel.modifiedFilters.withGenres
)
}
if viewModel.enabledFilterType.contains(.filter) {
MultiSelector(label: L10n.filters,
MultiSelector(
label: L10n.filters,
options: viewModel.possibleItemFilters,
optionToString: { $0.localized },
selected: $viewModel.modifiedFilters.filters)
selected: $viewModel.modifiedFilters.filters
)
}
if viewModel.enabledFilterType.contains(.tag) {
MultiSelector(label: L10n.tags,
MultiSelector(
label: L10n.tags,
options: viewModel.possibleTags,
optionToString: { $0 },
selected: $viewModel.modifiedFilters.tags)
selected: $viewModel.modifiedFilters.tags
)
}
if viewModel.enabledFilterType.contains(.sortBy) {
Picker(selection: $viewModel.selectedSortBy, label: L10n.sortBy.text) {

View File

@ -46,8 +46,10 @@ struct LibraryListView: View {
if itemType == .liveTV {
self.mainCoordinator.root(\.liveTV)
} else {
self.libraryListRouter.route(to: \.library,
(viewModel: LibraryViewModel(parentID: library.id), title: library.name ?? ""))
self.libraryListRouter.route(
to: \.library,
(viewModel: LibraryViewModel(parentID: library.id), title: library.name ?? "")
)
}
}
label: {

View File

@ -31,20 +31,30 @@ struct LibraryView: View {
ProgressView()
} else if !viewModel.rows.isEmpty {
CollectionView(rows: viewModel.rows) { _, _ in
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
heightDimension: .fractionalHeight(1))
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .fractionalHeight(1)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(200),
heightDimension: .absolute(300))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
subitems: [item])
let groupSize = NSCollectionLayoutSize(
widthDimension: .absolute(200),
heightDimension: .absolute(300)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
subitems: [item]
)
let header =
NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
heightDimension: .absolute(44)),
NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(44)
),
elementKind: UICollectionView.elementKindSectionHeader,
alignment: .topLeading)
alignment: .topLeading
)
let section = NSCollectionLayoutSection(group: group)

View File

@ -88,19 +88,31 @@ struct LiveTVChannelItemElement: View {
.foregroundColor(Color.jellyfinPurple)
.padding(.init(top: 0, leading: 0, bottom: 8, trailing: 0))
programLabel(timeText: currentProgramText.timeDisplay, titleText: currentProgramText.title,
color: Color("TextHighlightColor"), font: Font.system(size: 20, weight: .bold, design: .default))
programLabel(
timeText: currentProgramText.timeDisplay,
titleText: currentProgramText.title,
color: Color("TextHighlightColor"),
font: Font.system(size: 20, weight: .bold, design: .default)
)
if !nextProgramsText.isEmpty,
let nextItem = nextProgramsText[0]
{
programLabel(timeText: nextItem.timeDisplay, titleText: nextItem.title, color: Color.gray,
font: Font.system(size: 20, design: .default))
programLabel(
timeText: nextItem.timeDisplay,
titleText: nextItem.title,
color: Color.gray,
font: Font.system(size: 20, design: .default)
)
}
if nextProgramsText.count > 1,
let nextItem2 = nextProgramsText[1]
{
programLabel(timeText: nextItem2.timeDisplay, titleText: nextItem2.title, color: Color.gray,
font: Font.system(size: 20, design: .default))
programLabel(
timeText: nextItem2.timeDisplay,
titleText: nextItem2.title,
color: Color.gray,
font: Font.system(size: 20, design: .default)
)
}
}
.frame(maxHeight: .infinity, alignment: .top)
@ -128,8 +140,10 @@ struct LiveTVChannelItemElement: View {
}
}
}
.overlay(RoundedRectangle(cornerRadius: 20)
.stroke(isFocused ? Color.blue : Color.clear, lineWidth: 4))
.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(isFocused ? Color.blue : Color.clear, lineWidth: 4)
)
.cornerRadius(20)
.scaleEffect(isFocused ? 1.1 : 1)
.focusable(true)

View File

@ -66,7 +66,8 @@ struct LiveTVChannelsView: View {
}
return start > currentStart
}
LiveTVChannelItemElement(channel: channel,
LiveTVChannelItemElement(
channel: channel,
currentProgram: item.currentProgram,
currentProgramText: currentProgramDisplayText,
nextProgramsText: nextProgramsDisplayText(nextItems: nextItems, timeFormatter: viewModel.timeFormatter),
@ -78,7 +79,8 @@ struct LiveTVChannelsView: View {
loadingAction(false)
}
}
})
}
)
}
private func createGridLayout() -> NSCollectionLayoutSection {
@ -86,22 +88,32 @@ struct LiveTVChannelsView: View {
// But it does, even with contentInset = .zero and ignoreSafeArea.
let sideMargin = CGFloat(30)
let itemWidth = (UIScreen.main.bounds.width / 4) - (sideMargin * 2)
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(itemWidth),
heightDimension: .absolute(itemWidth))
let itemSize = NSCollectionLayoutSize(
widthDimension: .absolute(itemWidth),
heightDimension: .absolute(itemWidth)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
item.edgeSpacing = .init(leading: .fixed(8),
item.edgeSpacing = .init(
leading: .fixed(8),
top: .fixed(8),
trailing: .fixed(8),
bottom: .fixed(8))
bottom: .fixed(8)
)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(itemWidth))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
subitems: [item])
group.edgeSpacing = .init(leading: .fixed(0),
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(itemWidth)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
subitems: [item]
)
group.edgeSpacing = .init(
leading: .fixed(0),
top: .fixed(16),
trailing: .fixed(0),
bottom: .fixed(16))
bottom: .fixed(16)
)
group.contentInsets = .zero
let section = NSCollectionLayoutSection(group: group)

View File

@ -22,20 +22,30 @@ struct MovieLibrariesView: View {
ProgressView()
} else if !viewModel.rows.isEmpty {
CollectionView(rows: viewModel.rows) { _, _ in
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
heightDimension: .fractionalHeight(1))
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .fractionalHeight(1)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(200),
heightDimension: .absolute(300))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
subitems: [item])
let groupSize = NSCollectionLayoutSize(
widthDimension: .absolute(200),
heightDimension: .absolute(300)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
subitems: [item]
)
let header =
NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
heightDimension: .absolute(44)),
NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(44)
),
elementKind: UICollectionView.elementKindSectionHeader,
alignment: .topLeading)
alignment: .topLeading
)
let section = NSCollectionLayoutSection(group: group)

View File

@ -22,20 +22,30 @@ struct TVLibrariesView: View {
ProgressView()
} else if !viewModel.rows.isEmpty {
CollectionView(rows: viewModel.rows) { _, _ in
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
heightDimension: .fractionalHeight(1))
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .fractionalHeight(1)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(200),
heightDimension: .absolute(300))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
subitems: [item])
let groupSize = NSCollectionLayoutSize(
widthDimension: .absolute(200),
heightDimension: .absolute(300)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
subitems: [item]
)
let header =
NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
heightDimension: .absolute(44)),
NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(44)
),
elementKind: UICollectionView.elementKindSectionHeader,
alignment: .topLeading)
alignment: .topLeading
)
let section = NSCollectionLayoutSection(group: group)

View File

@ -55,9 +55,11 @@ struct UserSignInView: View {
}
}
.alert(item: $viewModel.errorMessage) { _ in
Alert(title: Text(viewModel.alertTitle),
Alert(
title: Text(viewModel.alertTitle),
message: Text(viewModel.errorMessage?.message ?? L10n.unknownError),
dismissButton: .cancel())
dismissButton: .cancel()
)
}
.navigationTitle(L10n.signIn)
}

View File

@ -126,12 +126,24 @@ class LiveTVPlayerViewController: UIViewController {
addButtonPressRecognizer(pressType: .menu, action: #selector(didPressMenu))
let defaultNotificationCenter = NotificationCenter.default
defaultNotificationCenter.addObserver(self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification,
object: nil)
defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive),
name: UIApplication.willResignActiveNotification, object: nil)
defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive),
name: UIApplication.didEnterBackgroundNotification, object: nil)
defaultNotificationCenter.addObserver(
self,
selector: #selector(appWillTerminate),
name: UIApplication.willTerminateNotification,
object: nil
)
defaultNotificationCenter.addObserver(
self,
selector: #selector(appWillResignActive),
name: UIApplication.willResignActiveNotification,
object: nil
)
defaultNotificationCenter.addObserver(
self,
selector: #selector(appWillResignActive),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
}
@objc
@ -659,8 +671,13 @@ extension LiveTVPlayerViewController {
private func restartOverlayDismissTimer(interval: Double = 5) {
self.overlayDismissTimer?.invalidate()
self.overlayDismissTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(dismissTimerFired),
userInfo: nil, repeats: false)
self.overlayDismissTimer = Timer.scheduledTimer(
timeInterval: interval,
target: self,
selector: #selector(dismissTimerFired),
userInfo: nil,
repeats: false
)
}
@objc
@ -679,9 +696,13 @@ extension LiveTVPlayerViewController {
private func restartConfirmCloseDismissTimer() {
self.confirmCloseOverlayDismissTimer?.invalidate()
self.confirmCloseOverlayDismissTimer = Timer.scheduledTimer(timeInterval: 5, target: self,
selector: #selector(confirmCloseTimerFired), userInfo: nil,
repeats: false)
self.confirmCloseOverlayDismissTimer = Timer.scheduledTimer(
timeInterval: 5,
target: self,
selector: #selector(confirmCloseTimerFired),
userInfo: nil,
repeats: false
)
}
@objc
@ -760,7 +781,7 @@ extension LiveTVPlayerViewController: PlayerOverlayDelegate {
func didSelectAudioStream(index: Int) {
// on live tv, it seems this gets set to -1 which disables the audio track.
// vlcMediaPlayer.currentAudioTrackIndex = Int32(index)
// vlcMediaPlayer.currentAudioTrackIndex = Int32(index)
viewModel.sendProgressReport()

View File

@ -58,19 +58,20 @@ class NativePlayerViewController: AVPlayerViewController {
.commonIdentifierTitle: viewModel.title,
.iTunesMetadataTrackSubTitle: viewModel.subtitle ?? "",
// Need to fix against an image that doesn't exist
// .commonIdentifierArtwork: UIImage(data: try! Data(contentsOf: viewModel.item.getBackdropImage(maxWidth: 200)))?
// .pngData() as Any,
// .commonIdentifierDescription: viewModel.item.overview ?? "",
// .iTunesMetadataContentRating: viewModel.item.officialRating ?? "",
// .quickTimeMetadataGenre: viewModel.item.genres?.first ?? "",
// .commonIdentifierArtwork: UIImage(data: try! Data(contentsOf: viewModel.item.getBackdropImage(maxWidth: 200)))?
// .pngData() as Any,
// .commonIdentifierDescription: viewModel.item.overview ?? "",
// .iTunesMetadataContentRating: viewModel.item.officialRating ?? "",
// .quickTimeMetadataGenre: viewModel.item.genres?.first ?? "",
]
return allMetadata.compactMap { createMetadataItem(for: $0, value: $1) }
}
private func createMetadataItem(for identifier: AVMetadataIdentifier,
value: Any) -> AVMetadataItem
{
private func createMetadataItem(
for identifier: AVMetadataIdentifier,
value: Any
) -> AVMetadataItem {
let item = AVMutableMetadataItem()
item.identifier = identifier
item.value = value as? NSCopying & NSObjectProtocol
@ -105,11 +106,14 @@ class NativePlayerViewController: AVPlayerViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
player?.seek(to: CMTimeMake(value: viewModel.currentSecondTicks, timescale: 10_000_000),
toleranceBefore: CMTimeMake(value: 1, timescale: 1), toleranceAfter: CMTimeMake(value: 1, timescale: 1),
player?.seek(
to: CMTimeMake(value: viewModel.currentSecondTicks, timescale: 10_000_000),
toleranceBefore: CMTimeMake(value: 1, timescale: 1),
toleranceAfter: CMTimeMake(value: 1, timescale: 1),
completionHandler: { _ in
self.play()
})
}
)
}
private func play() {

View File

@ -61,9 +61,11 @@ struct SmallMediaStreamSelectionView: View {
var body: some View {
ZStack(alignment: .bottom) {
LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]),
LinearGradient(
gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]),
startPoint: .top,
endPoint: .bottom)
endPoint: .bottom
)
.ignoresSafeArea()
.frame(height: 300)

View File

@ -32,9 +32,11 @@ struct tvOSLiveTVOverlay: View {
var body: some View {
ZStack(alignment: .bottom) {
LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]),
LinearGradient(
gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]),
startPoint: .top,
endPoint: .bottom)
endPoint: .bottom
)
.ignoresSafeArea()
.frame(height: viewModel.subtitle == nil ? 180 : 210)
@ -138,7 +140,8 @@ struct tvOSLiveTVOverlay: View {
struct tvOSLiveTVOverlay_Previews: PreviewProvider {
static let videoPlayerViewModel = VideoPlayerViewModel(item: BaseItemDto(),
static let videoPlayerViewModel = VideoPlayerViewModel(
item: BaseItemDto(),
title: "Glorious Purpose",
subtitle: "Loki - S1E1",
directStreamURL: URL(string: "www.apple.com")!,
@ -159,7 +162,8 @@ struct tvOSLiveTVOverlay_Previews: PreviewProvider {
shouldShowAutoPlay: true,
container: "",
filename: nil,
versionName: nil)
versionName: nil
)
static var previews: some View {
ZStack {

View File

@ -32,9 +32,11 @@ struct tvOSVLCOverlay: View {
var body: some View {
ZStack(alignment: .bottom) {
LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]),
LinearGradient(
gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]),
startPoint: .top,
endPoint: .bottom)
endPoint: .bottom
)
.ignoresSafeArea()
.frame(height: viewModel.subtitle == nil ? 180 : 210)
@ -138,7 +140,8 @@ struct tvOSVLCOverlay: View {
struct tvOSVLCOverlay_Previews: PreviewProvider {
static let videoPlayerViewModel = VideoPlayerViewModel(item: BaseItemDto(),
static let videoPlayerViewModel = VideoPlayerViewModel(
item: BaseItemDto(),
title: "Glorious Purpose",
subtitle: "Loki - S1E1",
directStreamURL: URL(string: "www.apple.com")!,
@ -159,7 +162,8 @@ struct tvOSVLCOverlay_Previews: PreviewProvider {
shouldShowAutoPlay: true,
container: "",
filename: nil,
versionName: nil)
versionName: nil
)
static var previews: some View {
ZStack {

View File

@ -126,12 +126,24 @@ class VLCPlayerViewController: UIViewController {
addButtonPressRecognizer(pressType: .menu, action: #selector(didPressMenu))
let defaultNotificationCenter = NotificationCenter.default
defaultNotificationCenter.addObserver(self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification,
object: nil)
defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive),
name: UIApplication.willResignActiveNotification, object: nil)
defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive),
name: UIApplication.didEnterBackgroundNotification, object: nil)
defaultNotificationCenter.addObserver(
self,
selector: #selector(appWillTerminate),
name: UIApplication.willTerminateNotification,
object: nil
)
defaultNotificationCenter.addObserver(
self,
selector: #selector(appWillResignActive),
name: UIApplication.willResignActiveNotification,
object: nil
)
defaultNotificationCenter.addObserver(
self,
selector: #selector(appWillResignActive),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
}
@objc
@ -659,8 +671,13 @@ extension VLCPlayerViewController {
private func restartOverlayDismissTimer(interval: Double = 5) {
self.overlayDismissTimer?.invalidate()
self.overlayDismissTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(dismissTimerFired),
userInfo: nil, repeats: false)
self.overlayDismissTimer = Timer.scheduledTimer(
timeInterval: interval,
target: self,
selector: #selector(dismissTimerFired),
userInfo: nil,
repeats: false
)
}
@objc
@ -679,9 +696,13 @@ extension VLCPlayerViewController {
private func restartConfirmCloseDismissTimer() {
self.confirmCloseOverlayDismissTimer?.invalidate()
self.confirmCloseOverlayDismissTimer = Timer.scheduledTimer(timeInterval: 5, target: self,
selector: #selector(confirmCloseTimerFired), userInfo: nil,
repeats: false)
self.confirmCloseOverlayDismissTimer = Timer.scheduledTimer(
timeInterval: 5,
target: self,
selector: #selector(confirmCloseTimerFired),
userInfo: nil,
repeats: false
)
}
@objc

View File

@ -331,8 +331,12 @@ public final class TvOSSlider: UIControl {
setUpGestures()
NotificationCenter.default.addObserver(self, selector: #selector(controllerConnected(note:)), name: .GCControllerDidConnect,
object: nil)
NotificationCenter.default.addObserver(
self,
selector: #selector(controllerConnected(note:)),
name: .GCControllerDidConnect,
object: nil
)
updateStateDependantViews()
}
@ -429,9 +433,7 @@ public final class TvOSSlider: UIControl {
let threshold: Float = 0.7
micro.reportsAbsoluteDpadValues = true
micro.dpad.valueChangedHandler = {
[weak self] _, x, _ in
micro.dpad.valueChangedHandler = { [weak self] _, x, _ in
if x < -threshold {
self?.dPadState = .left
} else if x > threshold {
@ -514,8 +516,13 @@ public final class TvOSSlider: UIControl {
if abs(velocity) > fineTunningVelocityThreshold {
let direction: Float = velocity > 0 ? 1 : -1
deceleratingVelocity = abs(velocity) > decelerationMaxVelocity ? decelerationMaxVelocity * direction : velocity
deceleratingTimer = Timer.scheduledTimer(timeInterval: 0.01, target: self,
selector: #selector(handleDeceleratingTimer(timer:)), userInfo: nil, repeats: true)
deceleratingTimer = Timer.scheduledTimer(
timeInterval: 0.01,
target: self,
selector: #selector(handleDeceleratingTimer(timer:)),
userInfo: nil,
repeats: true
)
} else {
viewModel.sliderIsScrubbing = false
stopDeceleratingTimer()

View File

@ -13,9 +13,10 @@ import UIKit
class AppDelegate: NSObject, UIApplicationDelegate {
static var orientationLock = UIInterfaceOrientationMask.all
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool
{
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
// Lazily initialize datastack
_ = SwiftfinStore.dataStack

View File

@ -14,14 +14,16 @@ import UIKit
class PreferenceUIHostingController: UIHostingController<AnyView> {
init<V: View>(wrappedView: V) {
let box = Box()
super.init(rootView: AnyView(wrappedView
super.init(rootView: AnyView(
wrappedView
.onPreferenceChange(PrefersHomeIndicatorAutoHiddenPreferenceKey.self) {
box.value?._prefersHomeIndicatorAutoHidden = $0
}.onPreferenceChange(SupportedOrientationsPreferenceKey.self) {
box.value?._orientations = $0
}.onPreferenceChange(ViewPreferenceKey.self) {
box.value?._viewPreference = $0
}))
}
))
box.value = self
}

View File

@ -57,9 +57,10 @@ struct DetectBottomScrollView<Content: View>: View {
let content: () -> Content
let didReachBottom: (Bool) -> Void
init(content: @escaping () -> Content,
didReachBottom: @escaping (Bool) -> Void)
{
init(
content: @escaping () -> Content,
didReachBottom: @escaping (Bool) -> Void
) {
self.content = content
self.didReachBottom = didReachBottom
}
@ -70,10 +71,13 @@ struct DetectBottomScrollView<Content: View>: View {
ChildSizeReader(size: $scrollViewSize) {
content()
.background(GeometryReader { proxy in
Color.clear.preference(key: ViewOffsetKey.self,
value: -1 * proxy.frame(in: .named(spaceName)).origin.y)
Color.clear.preference(
key: ViewOffsetKey.self,
value: -1 * proxy.frame(in: .named(spaceName)).origin.y
)
})
.onPreferenceChange(ViewOffsetKey.self,
.onPreferenceChange(
ViewOffsetKey.self,
perform: { value in
if value >= scrollViewSize.height - wholeSize.height {
@ -87,7 +91,8 @@ struct DetectBottomScrollView<Content: View>: View {
didReachBottom(false)
}
}
})
}
)
}
}
.coordinateSpace(name: spaceName)

View File

@ -23,8 +23,10 @@ struct EpisodeRowCard: View {
HStack(alignment: .top) {
VStack(alignment: .leading) {
ImageView(episode.getBackdropImage(maxWidth: 200),
blurHash: episode.getBackdropImageBlurHash())
ImageView(
episode.getBackdropImage(maxWidth: 200),
blurHash: episode.getBackdropImageBlurHash()
)
.mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10))
.frame(width: 200, height: 112)
.overlay {

View File

@ -29,8 +29,10 @@ struct EpisodesRowView<RowManager>: View where RowManager: EpisodesRowManager {
}
} else {
Menu {
ForEach(viewModel.sortedSeasons,
id: \.self) { season in
ForEach(
viewModel.sortedSeasons,
id: \.self
) { season in
Button {
viewModel.select(season: season)
} label: {

View File

@ -17,13 +17,14 @@ struct PortraitImageHStackView<TopBarView: View, ItemType: PortraitImageStackabl
let topBarView: () -> TopBarView
let selectedAction: (ItemType) -> Void
init(items: [ItemType],
init(
items: [ItemType],
maxWidth: CGFloat = 110,
horizontalAlignment: HorizontalAlignment = .leading,
textAlignment: TextAlignment = .leading,
topBarView: @escaping () -> TopBarView,
selectedAction: @escaping (ItemType) -> Void)
{
selectedAction: @escaping (ItemType) -> Void
) {
self.items = items
self.maxWidth = maxWidth
self.horizontalAlignment = horizontalAlignment
@ -43,11 +44,13 @@ struct PortraitImageHStackView<TopBarView: View, ItemType: PortraitImageStackabl
selectedAction(item)
} label: {
VStack(alignment: horizontalAlignment) {
ImageView(item.imageURLConstructor(maxWidth: Int(maxWidth)),
ImageView(
item.imageURLConstructor(maxWidth: Int(maxWidth)),
blurHash: item.blurHash,
failureView: {
InitialFailureView(item.failureInitials)
})
}
)
.portraitPoster(width: maxWidth)
.shadow(radius: 4, y: 2)
.accessibilityIgnoresInvertColors()

View File

@ -17,12 +17,13 @@ struct PortraitItemButton<ItemType: PortraitImageStackable>: View {
let textAlignment: TextAlignment
let selectedAction: (ItemType) -> Void
init(item: ItemType,
init(
item: ItemType,
maxWidth: CGFloat = 110,
horizontalAlignment: HorizontalAlignment = .leading,
textAlignment: TextAlignment = .leading,
selectedAction: @escaping (ItemType) -> Void)
{
selectedAction: @escaping (ItemType) -> Void
) {
self.item = item
self.maxWidth = maxWidth
self.horizontalAlignment = horizontalAlignment
@ -35,11 +36,13 @@ struct PortraitItemButton<ItemType: PortraitImageStackable>: View {
selectedAction(item)
} label: {
VStack(alignment: horizontalAlignment) {
ImageView(item.imageURLConstructor(maxWidth: Int(maxWidth)),
ImageView(
item.imageURLConstructor(maxWidth: Int(maxWidth)),
blurHash: item.blurHash,
failureView: {
InitialFailureView(item.failureInitials)
})
}
)
.portraitPoster(width: maxWidth)
.shadow(radius: 4, y: 2)
.accessibilityIgnoresInvertColors()

View File

@ -27,11 +27,12 @@ struct TruncatedTextView: View {
}
}
init(_ text: String,
init(
_ text: String,
lineLimit: Int,
font: UIFont = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.body),
seeMoreAction: @escaping () -> Void)
{
seeMoreAction: @escaping () -> Void
) {
self.text = text
self.lineLimit = lineLimit
_shrinkText = State(wrappedValue: text)
@ -45,13 +46,15 @@ struct TruncatedTextView: View {
Text(shrinkText)
.overlay {
if truncated {
LinearGradient(stops: [
LinearGradient(
stops: [
.init(color: .systemBackground.opacity(0), location: 0.5),
.init(color: .systemBackground.opacity(0.8), location: 0.7),
.init(color: .systemBackground, location: 1),
],
startPoint: .top,
endPoint: .bottom)
endPoint: .bottom
)
}
}
}
@ -71,10 +74,12 @@ struct TruncatedTextView: View {
var mid = heigh
while (heigh - low) > 1 {
let attributedText = NSAttributedString(string: shrinkText, attributes: attributes)
let boundingRect = attributedText.boundingRect(with: size,
let boundingRect = attributedText.boundingRect(
with: size,
options: NSStringDrawingOptions
.usesLineFragmentOrigin,
context: nil)
context: nil
)
if boundingRect.size.height > visibleTextGeometry.size.height {
truncated = true
heigh = mid

View File

@ -47,8 +47,10 @@ struct AboutView: View {
.resizable()
.frame(width: 20, height: 20)
.foregroundColor(.primary)
Link(L10n.sourceCode,
destination: URL(string: "https://github.com/jellyfin/Swiftfin")!)
Link(
L10n.sourceCode,
destination: URL(string: "https://github.com/jellyfin/Swiftfin")!
)
.foregroundColor(.primary)
Spacer()
@ -59,8 +61,10 @@ struct AboutView: View {
HStack {
Image(systemName: "plus.circle.fill")
Link(L10n.requestFeature,
destination: URL(string: "https://github.com/jellyfin/Swiftfin/issues")!)
Link(
L10n.requestFeature,
destination: URL(string: "https://github.com/jellyfin/Swiftfin/issues")!
)
.foregroundColor(.primary)
Spacer()
@ -71,8 +75,10 @@ struct AboutView: View {
HStack {
Image(systemName: "xmark.circle.fill")
Link(L10n.reportIssue,
destination: URL(string: "https://github.com/jellyfin/Swiftfin/issues")!)
Link(
L10n.reportIssue,
destination: URL(string: "https://github.com/jellyfin/Swiftfin/issues")!
)
.foregroundColor(.primary)
Spacer()

View File

@ -102,17 +102,21 @@ struct ConnectToServerView: View {
.headerProminence(.increased)
}
.alert(item: $viewModel.errorMessage) { _ in
Alert(title: Text(viewModel.alertTitle),
Alert(
title: Text(viewModel.alertTitle),
message: Text(viewModel.errorMessage?.message ?? L10n.unknownError),
dismissButton: .cancel())
dismissButton: .cancel()
)
}
.alert(item: $viewModel.addServerURIPayload) { _ in
Alert(title: L10n.existingServer.text,
Alert(
title: L10n.existingServer.text,
message: L10n.serverAlreadyExistsPrompt(viewModel.addServerURIPayload?.server.name ?? "").text,
primaryButton: .default(L10n.addURL.text, action: {
viewModel.addURIToServer(addServerURIPayload: viewModel.backAddServerURIPayload!)
}),
secondaryButton: .cancel())
secondaryButton: .cancel()
)
}
.navigationTitle(L10n.connect)
.onAppear {

View File

@ -51,9 +51,11 @@ struct ContinueWatchingView: View {
ZStack(alignment: .bottom) {
LinearGradient(colors: [.clear, .black.opacity(0.5), .black.opacity(0.7)],
LinearGradient(
colors: [.clear, .black.opacity(0.5), .black.opacity(0.7)],
startPoint: .top,
endPoint: .bottom)
endPoint: .bottom
)
.frame(height: 35)
VStack(alignment: .leading, spacing: 0) {

View File

@ -56,8 +56,10 @@ struct HomeView: View {
}
if !viewModel.nextUpItems.isEmpty {
PortraitImageHStackView(items: viewModel.nextUpItems,
horizontalAlignment: .leading) {
PortraitImageHStackView(
items: viewModel.nextUpItems,
horizontalAlignment: .leading
) {
L10n.nextUp.text
.font(.title2)
.fontWeight(.bold)
@ -93,9 +95,13 @@ struct HomeView: View {
Button {
homeRouter
.route(to: \.library, (viewModel: .init(parentID: library.id!,
filters: viewModel.recentFilterSet),
title: library.name ?? ""))
.route(to: \.library, (
viewModel: .init(
parentID: library.id!,
filters: viewModel.recentFilterSet
),
title: library.name ?? ""
))
} label: {
HStack {
L10n.seeAll.text.font(.subheadline).fontWeight(.bold)

View File

@ -29,9 +29,11 @@ struct ItemViewBody: View {
if let itemOverview = viewModel.item.overview {
if hSizeClass == .compact && vSizeClass == .regular {
TruncatedTextView(itemOverview,
TruncatedTextView(
itemOverview,
lineLimit: 5,
font: UIFont.preferredFont(forTextStyle: .footnote)) {
font: UIFont.preferredFont(forTextStyle: .footnote)
) {
itemRouter.route(to: \.itemOverview, viewModel.item)
}
.padding(.horizontal)
@ -50,33 +52,40 @@ struct ItemViewBody: View {
// MARK: Seasons
if let seriesViewModel = viewModel as? SeriesItemViewModel {
PortraitImageHStackView(items: seriesViewModel.seasons,
PortraitImageHStackView(
items: seriesViewModel.seasons,
topBarView: {
L10n.seasons.text
.fontWeight(.semibold)
.padding()
.accessibility(addTraits: [.isHeader])
}, selectedAction: { season in
},
selectedAction: { season in
itemRouter.route(to: \.item, season)
})
}
)
}
// MARK: Genres
if let genres = viewModel.item.genreItems, !genres.isEmpty {
PillHStackView(title: L10n.genres,
PillHStackView(
title: L10n.genres,
items: genres,
selectedAction: { genre in
itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title))
})
}
)
.padding(.bottom)
}
// MARK: Studios
if let studios = viewModel.item.studios {
PillHStackView(title: L10n.studios,
items: studios) { studio in
PillHStackView(
title: L10n.studios,
items: studios
) { studio in
itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? ""))
}
.padding(.bottom)
@ -124,7 +133,8 @@ struct ItemViewBody: View {
if showCastAndCrew {
if let castAndCrew = viewModel.item.people, !castAndCrew.isEmpty {
PortraitImageHStackView(items: castAndCrew.filter { BaseItemPerson.DisplayedType.allCasesRaw.contains($0.type ?? "") },
PortraitImageHStackView(
items: castAndCrew.filter { BaseItemPerson.DisplayedType.allCasesRaw.contains($0.type ?? "") },
topBarView: {
L10n.castAndCrew.text
.fontWeight(.semibold)
@ -134,14 +144,16 @@ struct ItemViewBody: View {
},
selectedAction: { person in
itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title))
})
}
)
}
}
// MARK: Recommended
if !viewModel.similarItems.isEmpty {
PortraitImageHStackView(items: viewModel.similarItems,
PortraitImageHStackView(
items: viewModel.similarItems,
topBarView: {
L10n.recommended.text
.fontWeight(.semibold)
@ -151,7 +163,8 @@ struct ItemViewBody: View {
},
selectedAction: { item in
itemRouter.route(to: \.item, item)
})
}
)
}
// MARK: Details

View File

@ -24,8 +24,10 @@ struct ItemLandscapeMainView: View {
// MARK: Sidebar Image
VStack {
ImageView(viewModel.item.portraitHeaderViewURL(maxWidth: 130),
blurHash: viewModel.item.getPrimaryImageBlurHash())
ImageView(
viewModel.item.portraitHeaderViewURL(maxWidth: 130),
blurHash: viewModel.item.getPrimaryImageBlurHash()
)
.frame(width: 130, height: 195)
.cornerRadius(10)
.accessibilityIgnoresInvertColors()
@ -95,8 +97,10 @@ struct ItemLandscapeMainView: View {
ZStack {
// MARK: Backdrop
ImageView(viewModel.item.getBackdropImage(maxWidth: 200),
blurHash: viewModel.item.getBackdropImageBlurHash())
ImageView(
viewModel.item.getBackdropImage(maxWidth: 200),
blurHash: viewModel.item.getBackdropImageBlurHash()
)
.opacity(0.3)
.edgesIgnoringSafeArea(.all)
.blur(radius: 8)

View File

@ -62,8 +62,10 @@ struct ItemLandscapeTopBarView: View {
.fontWeight(.semibold)
.foregroundColor(.secondary)
.padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4))
.overlay(RoundedRectangle(cornerRadius: 2)
.stroke(Color.secondary, lineWidth: 1))
.overlay(
RoundedRectangle(cornerRadius: 2)
.stroke(Color.secondary, lineWidth: 1)
)
}
Spacer()

View File

@ -24,8 +24,10 @@ struct PortraitHeaderOverlayView: View {
// MARK: Portrait Image
ImageView(viewModel.item.portraitHeaderViewURL(maxWidth: 130),
blurHash: viewModel.item.getPrimaryImageBlurHash())
ImageView(
viewModel.item.portraitHeaderViewURL(maxWidth: 130),
blurHash: viewModel.item.getPrimaryImageBlurHash()
)
.portraitPoster(width: 130)
.accessibilityIgnoresInvertColors()
@ -79,8 +81,10 @@ struct PortraitHeaderOverlayView: View {
.foregroundColor(.secondary)
.lineLimit(1)
.padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4))
.overlay(RoundedRectangle(cornerRadius: 2)
.stroke(Color.secondary, lineWidth: 1))
.overlay(
RoundedRectangle(cornerRadius: 2)
.stroke(Color.secondary, lineWidth: 1)
)
}
}

View File

@ -19,8 +19,10 @@ struct ItemPortraitMainView: View {
// MARK: portraitHeaderView
var portraitHeaderView: some View {
ImageView(viewModel.item.getBackdropImage(maxWidth: Int(UIScreen.main.bounds.width)),
blurHash: viewModel.item.getBackdropImageBlurHash())
ImageView(
viewModel.item.getBackdropImage(maxWidth: Int(UIScreen.main.bounds.width)),
blurHash: viewModel.item.getBackdropImageBlurHash()
)
.opacity(0.4)
.blur(radius: 2.0)
.accessibilityIgnoresInvertColors()
@ -39,10 +41,12 @@ struct ItemPortraitMainView: View {
VStack(alignment: .leading) {
// MARK: ParallaxScrollView
ParallaxHeaderScrollView(header: portraitHeaderView,
ParallaxHeaderScrollView(
header: portraitHeaderView,
staticOverlayView: portraitStaticOverlayView,
overlayAlignment: .bottomLeading,
headerHeight: UIScreen.main.bounds.width * 0.5625) {
headerHeight: UIScreen.main.bounds.width * 0.5625
) {
VStack {
Spacer()
.frame(height: 70)

View File

@ -18,8 +18,10 @@ struct LatestMediaView<TopBarView: View>: View {
var topBarView: () -> TopBarView
var body: some View {
PortraitImageHStackView(items: viewModel.items,
horizontalAlignment: .leading) {
PortraitImageHStackView(
items: viewModel.items,
horizontalAlignment: .leading
) {
topBarView()
} selectedAction: { item in
homeRouter.route(to: \.item, item)

View File

@ -35,22 +35,28 @@ struct LibraryFilterView: View {
} else {
Form {
if viewModel.enabledFilterType.contains(.genre) {
MultiSelector(label: L10n.genres,
MultiSelector(
label: L10n.genres,
options: viewModel.possibleGenres,
optionToString: { $0.name ?? "" },
selected: $viewModel.modifiedFilters.withGenres)
selected: $viewModel.modifiedFilters.withGenres
)
}
if viewModel.enabledFilterType.contains(.filter) {
MultiSelector(label: L10n.filters,
MultiSelector(
label: L10n.filters,
options: viewModel.possibleItemFilters,
optionToString: { $0.localized },
selected: $viewModel.modifiedFilters.filters)
selected: $viewModel.modifiedFilters.filters
)
}
if viewModel.enabledFilterType.contains(.tag) {
MultiSelector(label: L10n.tags,
MultiSelector(
label: L10n.tags,
options: viewModel.possibleTags,
optionToString: { $0 },
selected: $viewModel.modifiedFilters.tags)
selected: $viewModel.modifiedFilters.tags
)
}
if viewModel.enabledFilterType.contains(.sortBy) {
Picker(selection: $viewModel.selectedSortBy, label: L10n.sortBy.text) {

View File

@ -33,8 +33,10 @@ struct LibraryListView: View {
ScrollView {
LazyVStack {
Button {
libraryListRouter.route(to: \.library,
(viewModel: LibraryViewModel(filters: viewModel.withFavorites), title: L10n.favorites))
libraryListRouter.route(
to: \.library,
(viewModel: LibraryViewModel(filters: viewModel.withFavorites), title: L10n.favorites)
)
} label: {
ZStack {
HStack {
@ -65,9 +67,13 @@ struct LibraryListView: View {
if itemType == .liveTV {
libraryListRouter.route(to: \.liveTV)
} else {
libraryListRouter.route(to: \.library,
(viewModel: LibraryViewModel(parentID: library.id),
title: library.name ?? ""))
libraryListRouter.route(
to: \.library,
(
viewModel: LibraryViewModel(parentID: library.id),
title: library.name ?? ""
)
)
}
} label: {
ZStack {

View File

@ -22,8 +22,10 @@ struct LibraryView: View {
var defaultFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name])
@State
private var tracks: [GridItem] = Array(repeating: .init(.flexible(), alignment: .top),
count: Int(UIScreen.main.bounds.size.width) / 125)
private var tracks: [GridItem] = Array(
repeating: .init(.flexible(), alignment: .top),
count: Int(UIScreen.main.bounds.size.width) / 125
)
func recalcTracks() {
tracks = Array(repeating: .init(.flexible(), alignment: .top), count: Int(UIScreen.main.bounds.size.width) / 125)
@ -82,8 +84,11 @@ struct LibraryView: View {
Button {
libraryRouter
.route(to: \.filter, (filters: $viewModel.filters, enabledFilterType: viewModel.enabledFilterType,
parentId: viewModel.parentID ?? ""))
.route(to: \.filter, (
filters: $viewModel.filters,
enabledFilterType: viewModel.enabledFilterType,
parentId: viewModel.parentID ?? ""
))
} label: {
Image(systemName: "line.horizontal.3.decrease.circle")
}

View File

@ -116,7 +116,9 @@ struct LiveTVChannelItemElement: View {
ProgressView()
}
}
.overlay(RoundedRectangle(cornerRadius: 0)
.stroke(Color.blue, lineWidth: 0))
.overlay(
RoundedRectangle(cornerRadius: 0)
.stroke(Color.blue, lineWidth: 0)
)
}
}

View File

@ -99,8 +99,11 @@ struct LiveTVChannelItemWideElement: View {
.foregroundColor(Color.jellyfinPurple)
.frame(alignment: .leading)
.padding(.init(top: 0, leading: 0, bottom: 4, trailing: 0))
programLabel(timeText: currentProgramText.timeDisplay, titleText: currentProgramText.title,
color: Color("TextHighlightColor"))
programLabel(
timeText: currentProgramText.timeDisplay,
titleText: currentProgramText.title,
color: Color("TextHighlightColor")
)
if !nextProgramsText.isEmpty,
let nextItem = nextProgramsText[0]
{

Some files were not shown because too many files have changed in this diff Show More