No Tab Characters and Before First for Argument and Parameter Wrapping (#482)
This commit is contained in:
parent
88f350b71e
commit
cfb3aa1faa
|
@ -7,7 +7,7 @@ on:
|
|||
jobs:
|
||||
build:
|
||||
name: "Lint 🧹"
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-12
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)!)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)!
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)"
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)")
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue