From ed91d39ad905ba8af6d3f2f2dcb7ff9eac6d8d1d Mon Sep 17 00:00:00 2001 From: Ashik K Date: Sat, 18 Oct 2025 11:47:41 +0200 Subject: [PATCH] Implement dual provider architecture (Jellyfin + Xtream) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for running both Jellyfin and Xtream providers simultaneously: - User can have 1 active JF account + 1 active XC account at same time - Tabs dynamically show/hide based on active providers - Account switcher allows switching between multiple saved accounts New Components: - XtreamSession: Session management for Xtream providers - SessionManager: Tracks both JF and XC sessions - XtreamView/ViewModel: New Xtream tab with Live TV categories - TabCoordinator: Dynamic tab building based on active providers Modified Components: - MainTabView: Injects SessionManager, rebuilds tabs on provider changes - TabItem: Added Xtream tab definition - MediaViewModel: Excludes Xtream channels (now in Xtream tab only) Scripts: - Added .claude/scripts/create-issue.sh for Gitea issue creation Closes: jellyflood-2 (partial) Related: jellyflood-1, jellyflood-5 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .claude/scripts/create-issue.sh | 60 ++++++++++ Shared/Coordinators/Tabs/MainTabView.swift | 38 +++---- Shared/Coordinators/Tabs/TabCoordinator.swift | 73 +++++++++++- Shared/Coordinators/Tabs/TabItem.swift | 8 ++ Shared/Services/SessionManager.swift | 87 ++++++++++++++ Shared/Services/XtreamSession.swift | 50 +++++++++ .../MediaViewModel/MediaViewModel.swift | 4 +- .../XtreamViewModel/XtreamViewModel.swift | 106 ++++++++++++++++++ Shared/Views/XtreamView/XtreamView.swift | 82 ++++++++++++++ 9 files changed, 476 insertions(+), 32 deletions(-) create mode 100755 .claude/scripts/create-issue.sh create mode 100644 Shared/Services/SessionManager.swift create mode 100644 Shared/Services/XtreamSession.swift create mode 100644 Shared/ViewModels/XtreamViewModel/XtreamViewModel.swift create mode 100644 Shared/Views/XtreamView/XtreamView.swift diff --git a/.claude/scripts/create-issue.sh b/.claude/scripts/create-issue.sh new file mode 100755 index 00000000..5ff2e53a --- /dev/null +++ b/.claude/scripts/create-issue.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# +# Create a Gitea issue for jellyflood +# Usage: ./create-issue.sh "Issue Title" "Issue Body" "feature|bug" +# + +set -e + +# Load environment variables +source ~/.zshrc + +# Gitea configuration +GITEA_URL="https://git.ashik.se" +REPO_OWNER="ashikk" +REPO_NAME="jellyflood" + +# Check for API token in environment +if [ -z "$GITEA_API_TOKEN" ]; then + echo "Error: GITEA_API_TOKEN environment variable not set" + echo "Please set it in your ~/.zshrc" + echo "Get a token at: $GITEA_URL/user/settings/applications" + exit 1 +fi + +# Check arguments +if [ $# -lt 2 ]; then + echo "Usage: $0 \"Issue Title\" \"Issue Body\" [feature|bug]" + echo "" + echo "Example:" + echo " $0 \"jellyflood-8: New Feature\" \"Description here\" \"feature\"" + exit 1 +fi + +TITLE="$1" +BODY="$2" +LABEL="${3:-feature}" + +# Add label to body +FULL_BODY="**Label:** $LABEL + +$BODY" + +# Create issue +echo "Creating issue: $TITLE" +RESPONSE=$(curl -s -X POST \ + -H "Authorization: token $GITEA_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg title "$TITLE" --arg body "$FULL_BODY" '{title: $title, body: $body}')" \ + "$GITEA_URL/api/v1/repos/$REPO_OWNER/$REPO_NAME/issues") + +ISSUE_NUMBER=$(echo "$RESPONSE" | jq -r '.number // "error"') + +if [ "$ISSUE_NUMBER" = "error" ]; then + echo "Error creating issue:" + echo "$RESPONSE" | jq -r '.message // .' + exit 1 +fi + +echo "✅ Issue #$ISSUE_NUMBER created successfully!" +echo "View at: $GITEA_URL/$REPO_OWNER/$REPO_NAME/issues/$ISSUE_NUMBER" diff --git a/Shared/Coordinators/Tabs/MainTabView.swift b/Shared/Coordinators/Tabs/MainTabView.swift index 78b46250..bf6a8f1b 100644 --- a/Shared/Coordinators/Tabs/MainTabView.swift +++ b/Shared/Coordinators/Tabs/MainTabView.swift @@ -15,32 +15,16 @@ import SwiftUI // TODO: fix weird tvOS icon rendering struct MainTabView: View { - #if os(iOS) + @Injected(\.sessionManager) + private var sessionManager + @StateObject - private var tabCoordinator = TabCoordinator { - TabItem.home - TabItem.search - TabItem.media + private var tabCoordinator: TabCoordinator + + init() { + let sessionManager = Container.shared.sessionManager() + _tabCoordinator = StateObject(wrappedValue: TabCoordinator(sessionManager: sessionManager)) } - #else - @StateObject - private var tabCoordinator = TabCoordinator { - TabItem.home - TabItem.library( - title: L10n.tvShows, - systemName: "tv", - filters: .init(itemTypes: [.series]) - ) - TabItem.library( - title: L10n.movies, - systemName: "film", - filters: .init(itemTypes: [.movie]) - ) - TabItem.search - TabItem.media - TabItem.settings - } - #endif @ViewBuilder var body: some View { @@ -65,5 +49,11 @@ struct MainTabView: View { .tag(tab.item.id) } } + .onReceive(sessionManager.$jellyfinSession) { _ in + tabCoordinator.refreshTabs() + } + .onReceive(sessionManager.$xtreamSession) { _ in + tabCoordinator.refreshTabs() + } } } diff --git a/Shared/Coordinators/Tabs/TabCoordinator.swift b/Shared/Coordinators/Tabs/TabCoordinator.swift index ef2038ae..5f3bf1fe 100644 --- a/Shared/Coordinators/Tabs/TabCoordinator.swift +++ b/Shared/Coordinators/Tabs/TabCoordinator.swift @@ -39,12 +39,73 @@ final class TabCoordinator: ObservableObject { @Published var tabs: [TabData] = [] - init(@ArrayBuilder tabs: () -> [TabItem]) { - let tabs = tabs() - self.tabs = tabs.map { tab in - let coordinator = NavigationCoordinator() - let event = TabItemSelectedPublisher() - return (tab, coordinator, event) + private let sessionManager: SessionManager + + init(sessionManager: SessionManager) { + self.sessionManager = sessionManager + refreshTabs() + } + + func refreshTabs() { + let newTabs = buildTabs() + + // Preserve navigation coordinators for existing tabs + let preservedTabs = newTabs.map { newTab in + if let existingTab = tabs.first(where: { $0.item.id == newTab.id }) { + return (newTab, existingTab.coordinator, existingTab.publisher) + } else { + let coordinator = NavigationCoordinator() + let publisher = TabItemSelectedPublisher() + return (newTab, coordinator, publisher) + } + } + + tabs = preservedTabs + + // Reset selected tab if it no longer exists + if let selectedID = selectedTabID, !tabs.contains(where: { $0.item.id == selectedID }) { + selectedTabID = tabs.first?.item.id } } + + private func buildTabs() -> [TabItem] { + var tabItems: [TabItem] = [] + + // Add Jellyfin tabs if JF session is active + if sessionManager.hasJellyfinProvider { + #if os(iOS) + tabItems.append(TabItem.home) + tabItems.append(TabItem.search) + tabItems.append(TabItem.media) + #else + tabItems.append(TabItem.home) + tabItems.append(TabItem.library( + title: L10n.tvShows, + systemName: "tv", + filters: .init(itemTypes: [.series]) + )) + tabItems.append(TabItem.library( + title: L10n.movies, + systemName: "film", + filters: .init(itemTypes: [.movie]) + )) + tabItems.append(TabItem.search) + tabItems.append(TabItem.media) + #endif + } + + // Add Xtream tab if XC session is active + if sessionManager.hasXtreamProvider { + tabItems.append(TabItem.xtream) + } + + // Always add settings on tvOS (if JF is active) + #if !os(iOS) + if sessionManager.hasJellyfinProvider { + tabItems.append(TabItem.settings) + } + #endif + + return tabItems + } } diff --git a/Shared/Coordinators/Tabs/TabItem.swift b/Shared/Coordinators/Tabs/TabItem.swift index 9ed9e38e..f1a9e3a4 100644 --- a/Shared/Coordinators/Tabs/TabItem.swift +++ b/Shared/Coordinators/Tabs/TabItem.swift @@ -76,6 +76,14 @@ extension TabItem { MediaView() } + static let xtream = TabItem( + id: "xtream", + title: "Xtream", + systemImage: "antenna.radiowaves.left.and.right" + ) { + XtreamView() + } + static let search = TabItem( id: "search", title: L10n.search, diff --git a/Shared/Services/SessionManager.swift b/Shared/Services/SessionManager.swift new file mode 100644 index 00000000..b53263d3 --- /dev/null +++ b/Shared/Services/SessionManager.swift @@ -0,0 +1,87 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import Combine +import Defaults +import Factory +import Foundation + +/// Manages active provider sessions (Jellyfin and/or Xtream) +final class SessionManager: ObservableObject { + + @Published + var jellyfinSession: UserSession? + + @Published + var xtreamSession: XtreamSession? + + private var cancellables = Set() + + init() { + // Initialize with current sessions + self.jellyfinSession = Container.shared.currentUserSession() + self.xtreamSession = Container.shared.currentXtreamSession() + + // Listen for session changes + NotificationCenter.default.publisher(for: .didSignIn) + .sink { [weak self] _ in + self?.refreshJellyfinSession() + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .didSignOut) + .sink { [weak self] _ in + self?.refreshJellyfinSession() + } + .store(in: &cancellables) + + // Listen for Xtream session changes + Defaults.publisher(.currentXtreamServerID) + .sink { [weak self] _ in + self?.refreshXtreamSession() + } + .store(in: &cancellables) + } + + var hasActiveProvider: Bool { + jellyfinSession != nil || xtreamSession != nil + } + + var hasJellyfinProvider: Bool { + jellyfinSession != nil + } + + var hasXtreamProvider: Bool { + xtreamSession != nil + } + + private func refreshJellyfinSession() { + jellyfinSession = Container.shared.currentUserSession() + } + + private func refreshXtreamSession() { + xtreamSession = Container.shared.currentXtreamSession() + } + + func signOutJellyfin() { + NotificationCenter.default.post(name: .didSignOut, object: nil) + } + + func signOutXtream() { + Defaults[.currentXtreamServerID] = nil + } +} + +extension Container { + + var sessionManager: Factory { + self { + SessionManager() + }.singleton + } +} diff --git a/Shared/Services/XtreamSession.swift b/Shared/Services/XtreamSession.swift new file mode 100644 index 00000000..9e37e1ae --- /dev/null +++ b/Shared/Services/XtreamSession.swift @@ -0,0 +1,50 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import Defaults +import Factory +import Foundation + +/// Session for an active Xtream Codes provider +final class XtreamSession { + + let server: XtreamServer + let client: XtreamAPIClient + + init(server: XtreamServer) { + self.server = server + self.client = XtreamAPIClient(server: server) + } + + var id: String { + server.id + } + + var name: String { + server.name + } +} + +extension Container { + + /// Current active Xtream session (if any) + var currentXtreamSession: Factory { + self { + guard let currentServerID = Defaults[.currentXtreamServerID] else { return nil } + + let servers = Defaults[.xtreamServers] + guard let server = servers.first(where: { $0.id == currentServerID }) else { + // Had last server ID but no saved server + Defaults[.currentXtreamServerID] = nil + return nil + } + + return XtreamSession(server: server) + }.cached + } +} diff --git a/Shared/ViewModels/MediaViewModel/MediaViewModel.swift b/Shared/ViewModels/MediaViewModel/MediaViewModel.swift index 4e51b256..a1fcad71 100644 --- a/Shared/ViewModels/MediaViewModel/MediaViewModel.swift +++ b/Shared/ViewModels/MediaViewModel/MediaViewModel.swift @@ -97,9 +97,9 @@ final class MediaViewModel: ViewModel, Stateful { // force it to `folders` for better view handling let supportedUserViews = try await (userViews.value.items ?? []) .filter { item in - // Include channels (which don't have collectionType) + // EXCLUDE channels - they are now in the Xtream tab if item.type == .channel { - return true + return false } // Include items with supported collectionTypes if let collectionType = item.collectionType { diff --git a/Shared/ViewModels/XtreamViewModel/XtreamViewModel.swift b/Shared/ViewModels/XtreamViewModel/XtreamViewModel.swift new file mode 100644 index 00000000..444cefd5 --- /dev/null +++ b/Shared/ViewModels/XtreamViewModel/XtreamViewModel.swift @@ -0,0 +1,106 @@ +// +// 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) 2025 Jellyfin & Jellyfin Contributors +// + +import Defaults +import Factory +import Foundation +import JellyfinAPI +import OrderedCollections + +final class XtreamViewModel: ViewModel, Stateful { + + @Injected(\.sessionManager) + private var sessionManager + + var xtreamSession: XtreamSession? { + sessionManager.xtreamSession + } + + // MARK: Action + + enum Action: Equatable { + case error(JellyfinAPIError) + case refresh + } + + // MARK: State + + enum State: Hashable { + case content + case error(JellyfinAPIError) + case initial + case refreshing + } + + @Published + var xtreamChannels: OrderedSet = [] + + @Published + var backgroundStates: Set = [] + + @Published + var state: State = .initial + + func respond(to action: Action) -> State { + switch action { + case let .error(error): + return .error(error) + case .refresh: + cancellables.removeAll() + + Task { + do { + try await refresh() + + await MainActor.run { + self.state = .content + } + } catch { + await MainActor.run { + self.state = .error(.init(error.localizedDescription)) + } + } + } + .store(in: &cancellables) + + return .refreshing + } + } + + private func refresh() async throws { + + guard let session = xtreamSession else { + throw XtreamAPIError.invalidResponse + } + + await MainActor.run { + xtreamChannels.removeAll() + } + + let categories = try await session.client.getLiveCategories() + + // Convert Xtream categories to BaseItemDto for now + // TODO: Create proper Xtream data models + let items = categories.map { category -> BaseItemDto in + var item = BaseItemDto() + item.id = category.categoryId + item.name = category.categoryName + item.type = .channel + return item + } + + await MainActor.run { + xtreamChannels.elements = items + } + } + + func randomItemImageSources(for channel: BaseItemDto) async throws -> [ImageSource] { + // TODO: Implement Xtream-specific image fetching + [] + } +} diff --git a/Shared/Views/XtreamView/XtreamView.swift b/Shared/Views/XtreamView/XtreamView.swift new file mode 100644 index 00000000..82ea43b5 --- /dev/null +++ b/Shared/Views/XtreamView/XtreamView.swift @@ -0,0 +1,82 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2025 Jellyfin & Jellyfin Contributors +// + +import CollectionVGrid +import Defaults +import Factory +import JellyfinAPI +import SwiftUI + +struct XtreamView: View { + + @Router + private var router + + @StateObject + private var viewModel = XtreamViewModel() + + private var layout: CollectionVGridLayout { + if UIDevice.isTV { + .columns(4, insets: .init(50), itemSpacing: 50, lineSpacing: 50) + } else if UIDevice.isPad { + .minWidth(200) + } else { + .columns(2) + } + } + + @ViewBuilder + private var content: some View { + CollectionVGrid( + uniqueElements: viewModel.xtreamChannels, + layout: layout + ) { channel in + MediaItem(viewModel: viewModel, type: .collectionFolder(channel)) { namespace in + let channelViewModel = ItemLibraryViewModel( + parent: channel, + filters: .default + ) + router.route(to: .library(viewModel: channelViewModel), in: namespace) + } + } + } + + @ViewBuilder + private func errorView(with error: some Error) -> some View { + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + } + + var body: some View { + ZStack { + Color.clear + + switch viewModel.state { + case .content: + content + case let .error(error): + errorView(with: error) + case .initial: + content + case .refreshing: + ProgressView() + } + } + .animation(.linear(duration: 0.1), value: viewModel.state) + .ignoresSafeArea() + .navigationTitle("Xtream") + .onFirstAppear { + viewModel.send(.refresh) + } + .if(UIDevice.isTV) { view in + view.toolbar(.hidden, for: .navigationBar) + } + } +}