diff --git a/Shared/Coordinators/HomeCoordinator.swift b/Shared/Coordinators/HomeCoordinator.swift index aba2cbe8b..cae2aff00 100644 --- a/Shared/Coordinators/HomeCoordinator.swift +++ b/Shared/Coordinators/HomeCoordinator.swift @@ -28,6 +28,8 @@ final class HomeCoordinator: NavigationCoordinatable { var item = makeItem @Route(.push) var library = makeLibrary + @Route(.modal) + var adminDashboard = makeAdminDashboard #endif #if os(tvOS) @@ -46,6 +48,10 @@ final class HomeCoordinator: NavigationCoordinatable { func makeLibrary(viewModel: PagingLibraryViewModel) -> LibraryCoordinator { LibraryCoordinator(viewModel: viewModel) } + + func makeAdminDashboard() -> NavigationViewCoordinator { + NavigationViewCoordinator(AdminDashboardCoordinator()) + } #endif @ViewBuilder diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index aac334e96..7fcfdacc8 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -40,6 +40,8 @@ internal enum L10n { internal static let active = L10n.tr("Localizable", "active", fallback: "Active") /// Activity internal static let activity = L10n.tr("Localizable", "activity", fallback: "Activity") + /// Activity Indicator + internal static let activityIndicator = L10n.tr("Localizable", "activityIndicator", fallback: "Activity Indicator") /// Actor internal static let actor = L10n.tr("Localizable", "actor", fallback: "Actor") /// Add diff --git a/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift b/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift index 7a0d2e702..7bfef0279 100644 --- a/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift +++ b/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift @@ -141,6 +141,14 @@ extension StoredValues.Keys { ) } + static var activeSessionIndicator: Key { + CurrentUserKey( + "activeSessionIndicator", + domain: "activeSessionIndicator", + default: false + ) + } + static var customDeviceProfiles: Key<[CustomDeviceProfile]> { CurrentUserKey( "customDeviceProfiles", diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 0f8224df2..8728574cf 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 4E026A8B2CE804E7005471B5 /* ResetUserPasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E026A8A2CE804E7005471B5 /* ResetUserPasswordView.swift */; }; 4E0A8FFB2CAF74D20014B047 /* TaskCompletionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */; }; 4E0A8FFC2CAF74D20014B047 /* TaskCompletionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */; }; + 4E10F7F32CC49BDE0032C4B7 /* ActiveSessionsIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E10F7F22CC49BCF0032C4B7 /* ActiveSessionsIndicator.swift */; }; 4E10C8112CC030CD0012CC9F /* DeviceDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E10C8102CC030C90012CC9F /* DeviceDetailsView.swift */; }; 4E10C8172CC0455A0012CC9F /* CompatibilitiesSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E10C8162CC045530012CC9F /* CompatibilitiesSection.swift */; }; 4E10C8192CC045700012CC9F /* CustomDeviceNameSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E10C8182CC045690012CC9F /* CustomDeviceNameSection.swift */; }; @@ -223,6 +224,10 @@ 4EC6C16B2C92999800FC904B /* TranscodeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC6C16A2C92999800FC904B /* TranscodeSection.swift */; }; 4ECDAA9E2C920A8E0030F2F5 /* TranscodeReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */; }; 4ECDAA9F2C920A8E0030F2F5 /* TranscodeReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECDAA9D2C920A8E0030F2F5 /* TranscodeReason.swift */; }; + 4EDBDCD12CBDD6590033D347 /* SessionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EDBDCD02CBDD6510033D347 /* SessionInfo.swift */; }; + 4EDBDCD22CBDD6590033D347 /* SessionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EDBDCD02CBDD6510033D347 /* SessionInfo.swift */; }; + 4EE11E1A2CC6A513004BF852 /* ActivityBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE11E192CC6A50D004BF852 /* ActivityBadge.swift */; }; + 4EE141692C8BABDF0045B661 /* ProgressSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EE141682C8BABDF0045B661 /* ProgressSection.swift */; }; 4ECF5D882D0A3D0200F066B1 /* AddAccessScheduleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECF5D812D0A3D0200F066B1 /* AddAccessScheduleView.swift */; }; 4ECF5D8A2D0A57EF00F066B1 /* DynamicDayOfWeek.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECF5D892D0A57EF00F066B1 /* DynamicDayOfWeek.swift */; }; 4ECF5D8B2D0A57EF00F066B1 /* DynamicDayOfWeek.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECF5D892D0A57EF00F066B1 /* DynamicDayOfWeek.swift */; }; @@ -1278,6 +1283,7 @@ 4E0195E32CE04678007844F4 /* ItemSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSection.swift; sourceTree = ""; }; 4E026A8A2CE804E7005471B5 /* ResetUserPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetUserPasswordView.swift; sourceTree = ""; }; 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskCompletionStatus.swift; sourceTree = ""; }; + 4E10F7F22CC49BCF0032C4B7 /* ActiveSessionsIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionsIndicator.swift; sourceTree = ""; }; 4E10C8102CC030C90012CC9F /* DeviceDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceDetailsView.swift; sourceTree = ""; }; 4E10C8162CC045530012CC9F /* CompatibilitiesSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompatibilitiesSection.swift; sourceTree = ""; }; 4E10C8182CC045690012CC9F /* CustomDeviceNameSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceNameSection.swift; sourceTree = ""; }; @@ -1448,6 +1454,8 @@ 4ED25CA02D07E3520010333C /* EditAccessScheduleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAccessScheduleView.swift; sourceTree = ""; }; 4ED25CA22D07E4990010333C /* EditAccessScheduleRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAccessScheduleRow.swift; sourceTree = ""; }; 4EDBDCD02CBDD6510033D347 /* SessionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionInfo.swift; sourceTree = ""; }; + 4EE11E192CC6A50D004BF852 /* ActivityBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityBadge.swift; sourceTree = ""; }; + 4EE141682C8BABDF0045B661 /* ProgressSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressSection.swift; sourceTree = ""; }; 4EDDB49B2D596E0700DA16E8 /* VersionMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionMenu.swift; sourceTree = ""; }; 4EE07CBA2D08B19100B0B636 /* ErrorMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMessage.swift; sourceTree = ""; }; 4EE141682C8BABDF0045B661 /* ActiveSessionProgressSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionProgressSection.swift; sourceTree = ""; }; @@ -3605,6 +3613,8 @@ 53F866422687A45400DCD1D7 /* Components */ = { isa = PBXGroup; children = ( + 4E10F7F22CC49BCF0032C4B7 /* ActiveSessionsIndicator.swift */, + 4EE11E192CC6A50D004BF852 /* ActivityBadge.swift */, E1D8429429346C6400D1041A /* BasicStepper.swift */, E133328C2953AE4B00EE76AB /* CircularProgressView.swift */, 4E661A242CEFE64200025C99 /* CountryPicker.swift */, @@ -6325,6 +6335,7 @@ E1CB75732C80E71800217C76 /* DirectPlayProfile.swift in Sources */, E1B490472967E2E500D3EDCE /* CoreStore.swift in Sources */, 6220D0C026D61C5000B8E046 /* ItemCoordinator.swift in Sources */, + 4EE11E1A2CC6A513004BF852 /* ActivityBadge.swift in Sources */, 4EC2B1A22CC96F6600D866BE /* ServerUsersViewModel.swift in Sources */, E1B90C6A2BBE68D5007027C8 /* OffsetScrollView.swift in Sources */, E18E01DB288747230022598C /* iPadOSEpisodeItemView.swift in Sources */, @@ -6658,6 +6669,7 @@ E13332912953B91000EE76AB /* DownloadTaskCoordinator.swift in Sources */, E43918662AD5C8310045A18C /* OnScenePhaseChangedModifier.swift in Sources */, E1579EA72B97DC1500A31CA1 /* Eventful.swift in Sources */, + 4E10F7F32CC49BDE0032C4B7 /* ActiveSessionsIndicator.swift in Sources */, E1B33ED128EB860A0073B0FD /* LargePlaybackButtons.swift in Sources */, E1549664296CA2EF00C4EF88 /* SwiftfinStore.swift in Sources */, E102313F2BCF8A3C009D71FC /* DetailedChannelView.swift in Sources */, diff --git a/Swiftfin/Components/ActiveSessionsIndicator.swift b/Swiftfin/Components/ActiveSessionsIndicator.swift new file mode 100644 index 000000000..b0d498294 --- /dev/null +++ b/Swiftfin/Components/ActiveSessionsIndicator.swift @@ -0,0 +1,108 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct ActiveSessionIndicator: View { + @ObservedObject + var viewModel = ActiveSessionsViewModel() + + let action: () -> Void + + // MARK: - View Model Update Timer + + private let timer = Timer.publish(every: 60, on: .main, in: .common) + .autoconnect() + + // MARK: - Session States + + var activeSessions: [SessionInfo] { + viewModel.sessions.compactMap(\.value.value).filter { + $0.nowPlayingItem != nil + } + } + + // MARK: - Initializer + + init(action: @escaping () -> Void) { + self.action = action + self.viewModel.send(.getSessions) + } + + // MARK: - Body + + var body: some View { + Button(action: action) { + contentView + .onReceive(timer) { _ in + viewModel.send(.getSessions) + } + } + } + + // MARK: - Content View + + @ViewBuilder + var contentView: some View { + switch viewModel.state { + case .content, .initial: + sessionsView + default: + errorView + } + } + + // MARK: - Sessions View + + var sessionsView: some View { + HStack(alignment: .bottom) { + imageView + .overlay { + if activeSessions.isNotEmpty { + ActivityBadge(value: activeSessions.count) + .foregroundStyle(.primary) + } + } + } + } + + // MARK: - Image View + + var imageView: some View { + Image(systemName: "waveform.path.ecg") + .resizable() + .scaledToFit() + .padding(4) + .frame(width: 25, height: 25) + .foregroundColor(.primary) + .background( + Circle() + .fill( + activeSessions.isNotEmpty + ? Color.accentColor.opacity(0.5) + : Color.secondary + ) + ) + } + + // MARK: - Error View + + var errorView: some View { + Image(systemName: "exclamationmark.triangle") + .resizable() + .scaledToFit() + .padding(4) + .frame(width: 25, height: 25) + .foregroundColor(.black) + .background( + Circle() + .fill(.yellow) + ) + } +} diff --git a/Swiftfin/Components/ActivityBadge.swift b/Swiftfin/Components/ActivityBadge.swift new file mode 100644 index 000000000..8ccc9424c --- /dev/null +++ b/Swiftfin/Components/ActivityBadge.swift @@ -0,0 +1,67 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct ActivityBadge: View { + + let value: Int + + private let size = 16.0 + private let x = 20.0 + private let y = 0.0 + + // MARK: - Body + + var body: some View { + ZStack { + Capsule() + .fill(Color.accentColor) + .frame(width: size * widthMultplier(), height: size, alignment: .topTrailing) + .overlay { + Capsule() + .stroke(Color.systemBackground, lineWidth: 1.5) + } + .position(x: x, y: y) + + if hasTwoOrLessDigits() { + Text("\(value)") + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.primary) + .position(x: x, y: y) + } else { + Text("99+") + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.primary) + .frame(width: size * widthMultplier(), height: size, alignment: .center) + .position(x: x, y: y) + } + } + .opacity(value == 0 ? 0 : 1) + } + + // MARK: - Check Number of Digits + + func hasTwoOrLessDigits() -> Bool { + value < 100 + } + + // MARK: - Get Badge Width + + func widthMultplier() -> Double { + if value < 10 { + return 1.0 + } else if value < 100 { + return 1.5 + } else { + return 2.0 + } + } +} diff --git a/Swiftfin/Views/HomeView/HomeView.swift b/Swiftfin/Views/HomeView/HomeView.swift index 564550fd4..a64b4b8e6 100644 --- a/Swiftfin/Views/HomeView/HomeView.swift +++ b/Swiftfin/Views/HomeView/HomeView.swift @@ -16,6 +16,9 @@ import SwiftUI // - indicated by snapping to the top struct HomeView: View { + @StoredValue(.User.activeSessionIndicator) + private var activeSessionIndicator + @Default(.Customization.nextUpPosterType) private var nextUpPosterType @Default(.Customization.Home.showRecentlyAdded) @@ -87,6 +90,13 @@ struct HomeView: View { ProgressView() } + if activeSessionIndicator { + ActiveSessionIndicator { + router.route(to: \.adminDashboard) + } + .foregroundStyle(.primary, .secondary, Color.accentColor) + } + SettingsBarButton( server: viewModel.userSession.server, user: viewModel.userSession.user diff --git a/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift index bc66cbb10..ae3252895 100644 --- a/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift +++ b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/Components/Sections/HomeSection.swift @@ -7,12 +7,19 @@ // import Defaults +import Factory import SwiftUI extension CustomizeViewsSettings { struct HomeSection: View { + @Injected(\.currentUserSession) + private var userSession + + @StoredValue(.User.activeSessionIndicator) + private var activeSessionIndicator + @Default(.Customization.Home.showRecentlyAdded) private var showRecentlyAdded @Default(.Customization.Home.maxNextUp) @@ -23,6 +30,10 @@ extension CustomizeViewsSettings { var body: some View { Section(L10n.home) { + if userSession?.user.isAdministrator ?? false { + Toggle(L10n.activityIndicator, isOn: $activeSessionIndicator) + } + Toggle(L10n.showRecentlyAdded, isOn: $showRecentlyAdded) Toggle(L10n.nextUpRewatch, isOn: $resumeNextUp) diff --git a/Swiftfin/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift index 26ccb35d2..4a1e664d9 100644 --- a/Swiftfin/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift +++ b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift @@ -7,12 +7,16 @@ // import Defaults +import Factory import SwiftUI // TODO: will be entirely re-organized struct CustomizeViewsSettings: View { + @Injected(\.currentUserSession) + private var userSesssion + @Default(.Customization.itemViewType) private var itemViewType @Default(.Customization.CinematicItemViewType.usePrimaryImage) diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index f8fb06726..dc6918ecc 100644 --- a/Translations/en.lproj/Localizable.strings +++ b/Translations/en.lproj/Localizable.strings @@ -1063,7 +1063,11 @@ /// Lock All Fields "lockAllFields" = "Lock All Fields"; -/// Locked Fields +// Activity Indicator - Setting +// Setting to enable/disable the HomeView Activity Indicator +// Appears in CustomizeSettingView +"activityIndicator" = "Activity Indicator"; + "lockedFields" = "Locked Fields"; /// Locked users