diff --git a/Shared/Components/ImageView.swift b/Shared/Components/ImageView.swift index 73c86a20..f5ddff85 100644 --- a/Shared/Components/ImageView.swift +++ b/Shared/Components/ImageView.swift @@ -25,19 +25,20 @@ private let imagePipeline = { // - instead of removing first source on failure, just safe index into sources // TODO: currently SVGs are only supported for logos, which are only used in a few places. // make it so when displaying an SVG there is a unified `image` caller modifier +// TODO: probably don't need both `placeholder` modifiers struct ImageView: View { @State private var sources: [ImageSource] private var image: (Image) -> any View - private var placeholder: (() -> any View)? + private var placeholder: ((ImageSource) -> any View)? private var failure: () -> any View @ViewBuilder private func _placeholder(_ currentSource: ImageSource) -> some View { if let placeholder = placeholder { - placeholder() + placeholder(currentSource) .eraseToAnyView() } else { DefaultPlaceholderView(blurHash: currentSource.blurHash) @@ -124,6 +125,10 @@ extension ImageView { } func placeholder(@ViewBuilder _ content: @escaping () -> any View) -> Self { + copy(modifying: \.placeholder, with: { _ in content() }) + } + + func placeholder(@ViewBuilder _ content: @escaping (ImageSource) -> any View) -> Self { copy(modifying: \.placeholder, with: content) } diff --git a/Swiftfin tvOS/Views/MediaView.swift b/Swiftfin tvOS/Views/MediaView/MediaItem.swift similarity index 55% rename from Swiftfin tvOS/Views/MediaView.swift rename to Swiftfin tvOS/Views/MediaView/MediaItem.swift index 8c4a7234..5e2282fe 100644 --- a/Swiftfin tvOS/Views/MediaView.swift +++ b/Swiftfin tvOS/Views/MediaView/MediaItem.swift @@ -6,71 +6,9 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // -import CollectionVGrid import Defaults -import JellyfinAPI -import Stinsen import SwiftUI -struct MediaView: View { - - @EnvironmentObject - private var mainRouter: MainCoordinator.Router - @EnvironmentObject - private var router: MediaCoordinator.Router - - @StateObject - private var viewModel = MediaViewModel() - - private var contentView: some View { - CollectionVGrid( - $viewModel.mediaItems, - layout: .columns(4, insets: .init(50), itemSpacing: 50, lineSpacing: 50) - ) { mediaType in - MediaItem(viewModel: viewModel, type: mediaType) - .onSelect { - switch mediaType { - case let .collectionFolder(item): - let viewModel = ItemLibraryViewModel( - parent: item, - filters: .default - ) - router.route(to: \.library, viewModel) - case .downloads: () - case .favorites: - let viewModel = ItemLibraryViewModel( - title: L10n.favorites, - filters: .favorites - ) - router.route(to: \.library, viewModel) - case .liveTV: - mainRouter.root(\.liveTV) - } - } - } - } - - var body: some View { - WrappedView { - Group { - switch viewModel.state { - case .content: - contentView - case let .error(error): - Text(error.localizedDescription) - case .initial, .refreshing: - ProgressView() - } - } - .transition(.opacity.animation(.linear(duration: 0.2))) - } - .ignoresSafeArea() - .onFirstAppear { - viewModel.send(.refresh) - } - } -} - extension MediaView { // TODO: custom view for folders and tv (allow customization?) @@ -94,6 +32,12 @@ extension MediaView { self.mediaType = type } + private var useTitleLabel: Bool { + useRandomImage || + mediaType == .downloads || + mediaType == .favorites + } + private func setImageSources() { Task { @MainActor in if useRandomImage { @@ -118,6 +62,18 @@ extension MediaView { .frame(alignment: .center) } + private func titleLabelOverlay(with content: Content) -> some View { + ZStack { + content + + Color.black + .opacity(0.5) + + titleLabel + .foregroundStyle(.white) + } + } + var body: some View { Button { onSelect() @@ -127,23 +83,15 @@ extension MediaView { ImageView(imageSources) .image { image in - if useRandomImage || - mediaType == .downloads || - mediaType == .favorites - { - ZStack { - image - - Color.black - .opacity(0.5) - - titleLabel - .foregroundStyle(.white) - } + if useTitleLabel { + titleLabelOverlay(with: image) } else { image } } + .placeholder { imageSource in + titleLabelOverlay(with: ImageView.DefaultPlaceholderView(blurHash: imageSource.blurHash)) + } .failure { ImageView.DefaultFailureView() .overlay { diff --git a/Swiftfin tvOS/Views/MediaView/MediaView.swift b/Swiftfin tvOS/Views/MediaView/MediaView.swift new file mode 100644 index 00000000..a179388e --- /dev/null +++ b/Swiftfin tvOS/Views/MediaView/MediaView.swift @@ -0,0 +1,72 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import CollectionVGrid +import Defaults +import JellyfinAPI +import Stinsen +import SwiftUI + +struct MediaView: View { + + @EnvironmentObject + private var mainRouter: MainCoordinator.Router + @EnvironmentObject + private var router: MediaCoordinator.Router + + @StateObject + private var viewModel = MediaViewModel() + + private var contentView: some View { + CollectionVGrid( + $viewModel.mediaItems, + layout: .columns(4, insets: .init(50), itemSpacing: 50, lineSpacing: 50) + ) { mediaType in + MediaItem(viewModel: viewModel, type: mediaType) + .onSelect { + switch mediaType { + case let .collectionFolder(item): + let viewModel = ItemLibraryViewModel( + parent: item, + filters: .default + ) + router.route(to: \.library, viewModel) + case .downloads: () + case .favorites: + let viewModel = ItemLibraryViewModel( + title: L10n.favorites, + filters: .favorites + ) + router.route(to: \.library, viewModel) + case .liveTV: + mainRouter.root(\.liveTV) + } + } + } + } + + var body: some View { + WrappedView { + Group { + switch viewModel.state { + case .content: + contentView + case let .error(error): + Text(error.localizedDescription) + case .initial, .refreshing: + ProgressView() + } + } + .transition(.opacity.animation(.linear(duration: 0.2))) + } + .ignoresSafeArea() + .onFirstAppear { + viewModel.send(.refresh) + } + } +} diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 944ad40d..ab1e30d3 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -195,6 +195,8 @@ E1002B652793CEE800E47059 /* ChapterInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfo.swift */; }; E1002B682793CFBA00E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B672793CFBA00E47059 /* Algorithms */; }; E1002B6B2793E36600E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B6A2793E36600E47059 /* Algorithms */; }; + E103DF902BCF2F1C000229B2 /* MediaItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E103DF8F2BCF2F1C000229B2 /* MediaItem.swift */; }; + E103DF952BCF31CD000229B2 /* MediaItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E103DF942BCF31CD000229B2 /* MediaItem.swift */; }; E1047E2327E5880000CB0D4A /* SystemImageContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */; }; E104C870296E087200C1C3F9 /* IndicatorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E104C86F296E087200C1C3F9 /* IndicatorSettingsView.swift */; }; E104C873296E0D0A00C1C3F9 /* IndicatorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E104C872296E0D0A00C1C3F9 /* IndicatorSettingsView.swift */; }; @@ -986,6 +988,8 @@ C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemElement.swift; sourceTree = ""; }; C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemElement.swift; sourceTree = ""; }; E1002B632793CEE700E47059 /* ChapterInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterInfo.swift; sourceTree = ""; }; + E103DF8F2BCF2F1C000229B2 /* MediaItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaItem.swift; sourceTree = ""; }; + E103DF942BCF31CD000229B2 /* MediaItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaItem.swift; sourceTree = ""; }; E1047E2227E5880000CB0D4A /* SystemImageContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemImageContentView.swift; sourceTree = ""; }; E104C86F296E087200C1C3F9 /* IndicatorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndicatorSettingsView.swift; sourceTree = ""; }; E104C872296E0D0A00C1C3F9 /* IndicatorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndicatorSettingsView.swift; sourceTree = ""; }; @@ -2052,6 +2056,32 @@ path = Components; sourceTree = ""; }; + E103DF912BCF2F1F000229B2 /* Components */ = { + isa = PBXGroup; + children = ( + E103DF8F2BCF2F1C000229B2 /* MediaItem.swift */, + ); + path = Components; + sourceTree = ""; + }; + E103DF922BCF2F23000229B2 /* MediaView */ = { + isa = PBXGroup; + children = ( + E103DF912BCF2F1F000229B2 /* Components */, + 6213388F265F83A900A81A2A /* MediaView.swift */, + ); + path = MediaView; + sourceTree = ""; + }; + E103DF932BCF31C5000229B2 /* MediaView */ = { + isa = PBXGroup; + children = ( + C4E508172703E8190045C9AB /* MediaView.swift */, + E103DF942BCF31CD000229B2 /* MediaItem.swift */, + ); + path = MediaView; + sourceTree = ""; + }; E107BB9127880A4000354E07 /* ItemViewModel */ = { isa = PBXGroup; children = ( @@ -2207,7 +2237,7 @@ C4BE078D27298817003F4AD1 /* LiveTVHomeView.swift */, C4BE07732725EB66003F4AD1 /* LiveTVProgramsView.swift */, E158C8D02A31947500C527C5 /* MediaSourceInfoView.swift */, - C4E508172703E8190045C9AB /* MediaView.swift */, + E103DF932BCF31C5000229B2 /* MediaView */, E1E1643928BAC2EF00323B0A /* SearchView.swift */, E193D54F2719430400900D82 /* ServerDetailView.swift */, E193D54A271941D300900D82 /* ServerListView.swift */, @@ -2279,7 +2309,7 @@ C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */, E170D104294D21FA0017224C /* MediaSourceInfoView.swift */, E19F6C5C28F5189300C5197E /* MediaStreamInfoView.swift */, - 6213388F265F83A900A81A2A /* MediaView.swift */, + E103DF922BCF2F23000229B2 /* MediaView */, E1EDA8D62B92C9D700F9A57E /* PagingLibraryView */, E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */, 53EE24E5265060780068F029 /* SearchView.swift */, @@ -3388,7 +3418,6 @@ E1575E7E293E77B5001665B1 /* ItemFilterCollection.swift in Sources */, C46DD8EF2A8FB56E0046A504 /* LiveBottomBarView.swift in Sources */, C46DD8EA2A8FB45C0046A504 /* LiveOverlay.swift in Sources */, - E1575E96293E7B1E001665B1 /* UIScrollView.swift in Sources */, E11E376D293E9CC1009EF240 /* VideoPlayerCoordinator.swift in Sources */, E1575E6F293E77B5001665B1 /* GestureAction.swift in Sources */, E18A8E7E28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, @@ -3475,6 +3504,7 @@ E1EF473A289A0F610034046B /* TruncatedText.swift in Sources */, E1C926112887565C002A7A66 /* ActionButtonHStack.swift in Sources */, E178859B2780F1F40094FBCF /* tvOSSlider.swift in Sources */, + E103DF952BCF31CD000229B2 /* MediaItem.swift in Sources */, E18E02252887492B0022598C /* PlainNavigationLinkButton.swift in Sources */, E1ED91192B95993300802036 /* TitledLibraryParent.swift in Sources */, 62E632E1267D30CA0063E547 /* ItemLibraryViewModel.swift in Sources */, @@ -3750,7 +3780,6 @@ E154966A296CA2EF00C4EF88 /* DownloadManager.swift in Sources */, C4BE07852728446F003F4AD1 /* LiveTVChannelsViewModel.swift in Sources */, E133328829538D8D00EE76AB /* Files.swift in Sources */, - E1D3043228D175CE00587289 /* StaticLibraryViewModel.swift in Sources */, C44FA6E12AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift in Sources */, E1401CA02937DFF500E8B599 /* AppIconSelectorView.swift in Sources */, E1092F4C29106F9F00163F57 /* GestureAction.swift in Sources */, @@ -3849,7 +3878,6 @@ E18E0207288749200022598C /* AttributeStyleModifier.swift in Sources */, E1002B642793CEE800E47059 /* ChapterInfo.swift in Sources */, C46DD8E52A8FA6510046A504 /* LiveTopBarView.swift in Sources */, - E1D3043A28D189C500587289 /* CastAndCrewLibraryView.swift in Sources */, E18E01AD288746AF0022598C /* DotHStack.swift in Sources */, E170D107294D23BA0017224C /* MediaSourceInfoCoordinator.swift in Sources */, E1937A61288F32DB00CB80AA /* Poster.swift in Sources */, @@ -3989,7 +4017,6 @@ E1E750682A33E9B400B2C1EE /* OverviewCard.swift in Sources */, E1CCF13128AC07EC006CAC9E /* PosterHStack.swift in Sources */, E13DD3C827164B1E009D4DAF /* UIDevice.swift in Sources */, - E1D3044228D1976600587289 /* CastAndCrewItemRow.swift in Sources */, C46DD8DD2A8DC3420046A504 /* LiveNativeVideoPlayer.swift in Sources */, E1AD104D26D96CE3003E4A08 /* BaseItemDto.swift in Sources */, E13DD3BF27163DD7009D4DAF /* AppDelegate.swift in Sources */, @@ -4021,6 +4048,7 @@ E1BDF2E52951475300CC0294 /* VideoPlayerActionButton.swift in Sources */, E133328F2953B71000EE76AB /* DownloadTaskView.swift in Sources */, E1E6C44029AECC6D0064123F /* ActionButtons.swift in Sources */, + E103DF902BCF2F1C000229B2 /* MediaItem.swift in Sources */, 539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */, E1E2F83F2B757DFA00B75998 /* OnFinalDisappearModifier.swift in Sources */, E15D4F072B1B12C300442DB8 /* Backport.swift in Sources */, diff --git a/Swiftfin/Views/MediaView.swift b/Swiftfin/Views/MediaView/Components/MediaItem.swift similarity index 51% rename from Swiftfin/Views/MediaView.swift rename to Swiftfin/Views/MediaView/Components/MediaItem.swift index 64d61482..9a28adfe 100644 --- a/Swiftfin/Views/MediaView.swift +++ b/Swiftfin/Views/MediaView/Components/MediaItem.swift @@ -6,96 +6,11 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // -import CollectionVGrid import Defaults -import Factory -import JellyfinAPI -import Stinsen import SwiftUI -// TODO: seems to redraw view when popped to sometimes? -// - similar to HomeView TODO bug? -// TODO: list view -// TODO: `afterLastDisappear` with `backgroundRefresh` -struct MediaView: View { - - @EnvironmentObject - private var router: MediaCoordinator.Router - - @StateObject - private var viewModel = MediaViewModel() - - private var padLayout: CollectionVGridLayout { - .minWidth(200) - } - - private var phoneLayout: CollectionVGridLayout { - .columns(2) - } - - private var contentView: some View { - CollectionVGrid( - $viewModel.mediaItems, - layout: UIDevice.isPhone ? phoneLayout : padLayout - ) { mediaType in - MediaItem(viewModel: viewModel, type: mediaType) - .onSelect { - switch mediaType { - case let .collectionFolder(item): - let viewModel = ItemLibraryViewModel( - parent: item, - filters: .default - ) - router.route(to: \.library, viewModel) - case .downloads: - router.route(to: \.downloads) - case .favorites: - let viewModel = ItemLibraryViewModel( - title: L10n.favorites, - filters: .favorites - ) - router.route(to: \.library, viewModel) - case .liveTV: - router.route(to: \.liveTV) - } - } - } - .refreshable { - viewModel.send(.refresh) - } - } - - private func errorView(with error: some Error) -> some View { - ErrorView(error: error) - .onRetry { - viewModel.send(.refresh) - } - } - - var body: some View { - WrappedView { - switch viewModel.state { - case .content: - contentView - case let .error(error): - errorView(with: error) - case .initial, .refreshing: - DelayedProgressView() - } - } - .transition(.opacity.animation(.linear(duration: 0.2))) - .ignoresSafeArea() - .navigationTitle(L10n.allMedia) - .topBarTrailing { - if viewModel.isLoading { - ProgressView() - } - } - .onFirstAppear { - viewModel.send(.refresh) - } - } -} +// Note: the design reason to not have a local label always on top +// is to have the same failure/empty color for all views extension MediaView { @@ -124,6 +39,12 @@ extension MediaView { self.mediaType = type } + private var useTitleLabel: Bool { + useRandomImage || + mediaType == .downloads || + mediaType == .favorites + } + private func setImageSources() { Task { @MainActor in if useRandomImage { @@ -148,6 +69,19 @@ extension MediaView { .frame(alignment: .center) } + // TODO: find a different way to do this local-label-wackiness if possible + private func titleLabelOverlay(with content: Content) -> some View { + ZStack { + content + + Color.black + .opacity(0.5) + + titleLabel + .foregroundStyle(.white) + } + } + var body: some View { Button { onSelect() @@ -157,23 +91,15 @@ extension MediaView { ImageView(imageSources) .image { image in - if useRandomImage || - mediaType == .downloads || - mediaType == .favorites - { - ZStack { - image - - Color.black - .opacity(0.5) - - titleLabel - .foregroundStyle(.white) - } + if useTitleLabel { + titleLabelOverlay(with: image) } else { image } } + .placeholder { imageSource in + titleLabelOverlay(with: ImageView.DefaultPlaceholderView(blurHash: imageSource.blurHash)) + } .failure { ImageView.DefaultFailureView() .overlay { diff --git a/Swiftfin/Views/MediaView/MediaView.swift b/Swiftfin/Views/MediaView/MediaView.swift new file mode 100644 index 00000000..a153b51a --- /dev/null +++ b/Swiftfin/Views/MediaView/MediaView.swift @@ -0,0 +1,98 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import CollectionVGrid +import Defaults +import Factory +import JellyfinAPI +import Stinsen +import SwiftUI + +// TODO: seems to redraw view when popped to sometimes? +// - similar to HomeView TODO bug? +// TODO: list view +// TODO: `afterLastDisappear` with `backgroundRefresh` +struct MediaView: View { + + @EnvironmentObject + private var router: MediaCoordinator.Router + + @StateObject + private var viewModel = MediaViewModel() + + private var padLayout: CollectionVGridLayout { + .minWidth(200) + } + + private var phoneLayout: CollectionVGridLayout { + .columns(2) + } + + private var contentView: some View { + CollectionVGrid( + $viewModel.mediaItems, + layout: UIDevice.isPhone ? phoneLayout : padLayout + ) { mediaType in + MediaItem(viewModel: viewModel, type: mediaType) + .onSelect { + switch mediaType { + case let .collectionFolder(item): + let viewModel = ItemLibraryViewModel( + parent: item, + filters: .default + ) + router.route(to: \.library, viewModel) + case .downloads: + router.route(to: \.downloads) + case .favorites: + let viewModel = ItemLibraryViewModel( + title: L10n.favorites, + filters: .favorites + ) + router.route(to: \.library, viewModel) + case .liveTV: + router.route(to: \.liveTV) + } + } + } + .refreshable { + viewModel.send(.refresh) + } + } + + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + } + + var body: some View { + WrappedView { + switch viewModel.state { + case .content: + contentView + case let .error(error): + errorView(with: error) + case .initial, .refreshing: + DelayedProgressView() + } + } + .transition(.opacity.animation(.linear(duration: 0.2))) + .ignoresSafeArea() + .navigationTitle(L10n.allMedia) + .topBarTrailing { + if viewModel.isLoading { + ProgressView() + } + } + .onFirstAppear { + viewModel.send(.refresh) + } + } +}