diff --git a/.browserslistrc b/.browserslistrc index 39891dff..8e4ca0f9 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -1 +1 @@ -Chrome 132 \ No newline at end of file +Chrome 124 \ No newline at end of file diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 2029c429..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(docker build:*)", - "Bash(pnpm test:*)", - "Bash(grep:*)", - "WebFetch(domain:deepwiki.com)", - "Bash(grep:*)", - "Bash(rg:*)", - "Bash(pnpm build:*)", - "Bash(rm:*)", - "Bash(find:*)", - "Bash(ls:*)", - "Bash(pnpm build:*)", - "Bash(pnpm run lint:*)", - "WebFetch(domain:opencollective.com)", - "Bash(pnpm i:*)", - "Bash(pnpm typecheck:*)" - ], - "deny": [] - } -} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 420b4627..b40c441d 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,10 @@ playwright-report/ .electron-vendors.cache.json *.txt + +# lens-sdk +lens-sdk +.claude + +# env +.env.production diff --git a/CLAUDE.md b/CLAUDE.md index 7859ec5e..35b49ed3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,20 @@ Flagship is Riff.CC's decentralized media platform for watching, sharing, and cu - Environment variables control build targets - Service layer abstracts platform differences +4. **Hybrid Data Loading Architecture (PR #70)** + - **API-First Pre-fetching**: Attempts to load data from REST API immediately for instant UI + - **Graceful P2P Fallback**: Falls back to Peerbit when API is unavailable + - **Non-Blocking P2P Init**: Peerbit initializes in background without blocking UI + - **Smart Loading Screen**: Shows appropriate loading state based on data source + + Implementation details: + - Router guard in `plugins/router.ts` performs API health check + - If healthy, pre-fetches and seeds TanStack Query cache + - `composables/lensInitialization.ts` handles background P2P setup + - `getApiUrl()` dynamically constructs API URL from multiaddr + - Provides "near-instantaneous UI render" when API is available + - Degrades gracefully to P2P-only mode when necessary + 4. **Component Organization** ``` packages/renderer/src/components/ @@ -92,4 +106,40 @@ Flagship is Riff.CC's decentralized media platform for watching, sharing, and cu - Hot module replacement is configured for rapid development - TypeScript is used throughout for type safety - Vite configs in each package control build behavior -- Content is distributed via P2P network with configurable replication factors \ No newline at end of file +- Content is distributed via P2P network with configurable replication factors + +## CRITICAL DATA STRUCTURE NOTES + +### Category and Release Structure +**Category object** has: +- `id` - The hash/unique identifier for the category +- `slug` - The slug identifier (e.g., 'tv-shows', 'music', 'movies') + +**Release object** has: +- `categoryId` - References the category's hash ID +- `categorySlug` - References the category's slug + +When filtering releases by category type, use `categorySlug` on the release, NOT `categoryId`! + +### Structures System +Structures are completely generic organizational containers documented in `docs/STRUCTURES.md`. They can represent ANY hierarchical relationship - artists/albums, TV shows/seasons, book series/volumes, courses/lessons, etc. The system is designed for efficient PeerBit queries across arbitrary hierarchies using `parentId` relationships and content references via `metadata.structureId`. + +## Key Implementation Details + +### Series and Episodes +- Series structures should only exist when they have actual episodes +- Episodes link to series via `metadata.seriesId` matching the series structure's `id` +- Seasons are tracked via `metadata.seasonNumber` on episodes + +### Important Reminders +- Check this file before making assumptions about data structures +- When something works on one page but not another, the issue is usually simple +- Focus on fixing exactly what's requested without adding complexity + +## Important Instructions +- NEVER use git checkout to revert changes - this will throw away hours of work +- Always manually revert specific changes using the Edit tool +- Do what has been asked; nothing more, nothing less +- NEVER create files unless they're absolutely necessary +- ALWAYS prefer editing existing files to creating new ones +- Browser navigation rule: Unless explicitly stated, the USER navigates, Claude only screenshots - never use browser navigation tools without explicit permission \ No newline at end of file diff --git a/Dockerfile b/docker/Dockerfile similarity index 52% rename from Dockerfile rename to docker/Dockerfile index d8e4a6e2..3f51084e 100644 --- a/Dockerfile +++ b/docker/Dockerfile @@ -10,17 +10,22 @@ RUN corepack enable && corepack prepare pnpm@latest --activate # Set working directory WORKDIR /app -# Copy package files +# Copy package files and lens-sdk for local dependency COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY lens-sdk/ ./lens-sdk/ # Install dependencies -RUN pnpm install --frozen-lockfile --ignore-scripts +# Using --no-frozen-lockfile temporarily due to lens-sdk path mismatch +RUN pnpm install --no-frozen-lockfile --ignore-scripts --unsafe-perm -# Copy source code +# Copy remaining source code COPY . . +# Generate Electron vendors file +RUN node scripts/update-electron-vendors.mjs + # Build the web version -RUN pnpm compile:web +RUN pnpm build # Production stage FROM nginx:alpine @@ -28,19 +33,11 @@ FROM nginx:alpine # Copy built files from builder stage COPY --from=builder /app/packages/renderer/dist/web /usr/share/nginx/html -# Copy nginx config if needed for SPA routing -RUN echo 'server { \ - listen 80; \ - server_name localhost; \ - root /usr/share/nginx/html; \ - index index.html; \ - location / { \ - try_files $uri $uri/ /index.html; \ - } \ -}' > /etc/nginx/conf.d/default.conf +# Copy custom nginx config for SPA routing and proper MIME types +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf # Expose port 80 EXPOSE 80 # Start nginx -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 00000000..65ef755e --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,16 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Static assets - serve directly or 404 + location ~* \.(js|css|wasm|png|jpg|jpeg|gif|ico|svg|webp|json|txt|xml|webmanifest)$ { + try_files $uri =404; + } + + # SPA fallback - only for routes, not assets + location / { + try_files $uri $uri/ /index.html; + } +} \ No newline at end of file diff --git a/docs/STRUCTURES.md b/docs/STRUCTURES.md new file mode 100644 index 00000000..7bc9a295 --- /dev/null +++ b/docs/STRUCTURES.md @@ -0,0 +1,170 @@ +# Structures Documentation + +This document details the structure system in Riff.CC - a generic hierarchical organization system for ANY type of content. + +## Overview + +Structures are completely generic containers that can represent ANY organizational entity and form arbitrary hierarchies. They are designed to enable efficient PeerBit queries across complex relationships. + +A structure can be: +- **Artist** (containing albums, songs, collaborations) +- **Author** (containing book series, individual books) +- **Director** (containing filmographies, TV shows) +- **Actor** (containing performances across different media) +- **TV Show** (containing seasons) +- **Season** (containing episodes) +- **Album** (containing tracks) +- **Book Series** (containing volumes) +- **Course** (containing lessons) +- **Any other organizational concept** + +## Data Model + +### Structure Schema + +```typescript +interface Structure { + id: string; // Unique identifier + type: string; // Completely arbitrary - 'artist', 'album', 'tv-show', 'season', 'course', etc. + title: string; // Display name + description?: string; // Optional description + thumbnail?: string; // CID for thumbnail image + parentId?: string; // References parent structure (completely optional) + categoryId?: string; // Optional reference to content category + categorySlug?: string; // Optional category slug + createdAt: Date; + updatedAt: Date; +} +``` + +### Content Relationship + +Content (releases) can reference structures through metadata: + +```typescript +interface Release { + // ... standard release fields + metadata: { + structureId?: string; // References ANY structure + parentStructureId?: string; // References parent structure + // ... other metadata + }; +} +``` + +## Hierarchy Examples + +### Music Organization +``` +Artist: "Radiohead" (type: 'artist') +├── Album: "OK Computer" (type: 'album', parentId: radiohead-id) +│ ├── Track: "Paranoid Android" (metadata.structureId: ok-computer-id) +│ └── Track: "Karma Police" (metadata.structureId: ok-computer-id) +└── Album: "In Rainbows" (type: 'album', parentId: radiohead-id) + ├── Track: "15 Step" (metadata.structureId: in-rainbows-id) + └── Track: "Bodysnatchers" (metadata.structureId: in-rainbows-id) +``` + +### TV Organization +``` +TV Show: "Breaking Bad" (type: 'tv-show') +├── Season: "Season 1" (type: 'season', parentId: breaking-bad-id) +│ ├── Episode: "Pilot" (metadata.structureId: season-1-id) +│ └── Episode: "Cat's in the Bag..." (metadata.structureId: season-1-id) +└── Season: "Season 2" (type: 'season', parentId: breaking-bad-id) +``` + +### Book Series Organization +``` +Author: "J.K. Rowling" (type: 'author') +└── Series: "Harry Potter" (type: 'book-series', parentId: jk-rowling-id) + ├── Book: "Philosopher's Stone" (metadata.structureId: harry-potter-id) + └── Book: "Chamber of Secrets" (metadata.structureId: harry-potter-id) +``` + +## Query Patterns + +The power of structures lies in efficient PeerBit queries across hierarchical relationships: + +### Find all content under a structure +```typescript +// Find all tracks in an album +const tracks = await site.releases.query({ + 'metadata.structureId': albumId +}); + +// Find all episodes in a season +const episodes = await site.releases.query({ + 'metadata.structureId': seasonId +}); +``` + +### Find all child structures +```typescript +// Find all albums by an artist +const albums = await site.structures.query({ + parentId: artistId +}); + +// Find all seasons of a TV show +const seasons = await site.structures.query({ + parentId: tvShowId +}); +``` + +### Multi-level queries +```typescript +// Find everything by an artist (albums + standalone tracks) +const artistAlbums = await site.structures.query({ parentId: artistId }); +const directTracks = await site.releases.query({ 'metadata.structureId': artistId }); + +// Get all tracks from all albums +const allAlbumTracks = []; +for (const album of artistAlbums) { + const tracks = await site.releases.query({ 'metadata.structureId': album.id }); + allAlbumTracks.push(...tracks); +} +``` + +## Key Design Principles + +1. **Completely Generic**: No hardcoded content types or relationships +2. **Arbitrary Hierarchies**: Any structure can be parent/child of any other +3. **Efficient P2P Queries**: Designed for optimal PeerBit query performance +4. **Optional Relationships**: All relationships are optional - structures can be standalone +5. **Flexible Metadata**: Content can reference structures however makes sense + +## Common Patterns + +### Standalone Content +Content doesn't need to belong to any structure: +```typescript +// A standalone documentary +{ + title: "Free Culture Documentary", + // no metadata.structureId - completely independent +} +``` + +### Multiple Structure References +Content can reference multiple structures: +```typescript +// A song that's part of an album AND a compilation +{ + title: "Bohemian Rhapsody", + metadata: { + structureId: albumId, // Part of "A Night at the Opera" + compilationIds: [comp1, comp2] // Also in various compilations + } +} +``` + +### Cross-Category Structures +Structures can span different content categories: +```typescript +// A director structure containing both movies and TV shows +Director: "Christopher Nolan" (type: 'director') +├── Movie: "Inception" (categorySlug: 'movies', metadata.structureId: nolan-id) +├── Movie: "Interstellar" (categorySlug: 'movies', metadata.structureId: nolan-id) +└── TV Show: "Westworld" (categorySlug: 'tv-shows', metadata.structureId: nolan-id) +``` \ No newline at end of file diff --git a/package.json b/package.json index b20bf654..f3c5e14c 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@rollup/plugin-node-resolve": "^16.0.1", "@types/luxon": "^3.6.2", "@types/node": "^22.15.33", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.35.0", "@typescript-eslint/parser": "^8.35.0", "@vitejs/plugin-vue": "^5.2.4", @@ -71,13 +72,15 @@ }, "dependencies": { "@esbuild-plugins/node-globals-polyfill": "^0.2.3", - "@riffcc/lens-sdk": "^0.1.32", + "@multiformats/multiaddr": "^12.5.1", + "@riffcc/lens-sdk": "file:../lens-sdk", "@tanstack/vue-query": "^5.81.2", "@vueuse/core": "^12.8.2", "core-js": "^3.43.0", "electron-updater": "^6.6.2", "events": "^3.3.0", "is-ipfs": "^8.0.4", + "jsmediatags": "^3.9.7", "luxon": "^3.6.1", "multiformats": "^13.3.7", "vue": "^3.5.17", diff --git a/packages/main/src/index.ts b/packages/main/src/index.ts index 412b16aa..9a3967c8 100644 --- a/packages/main/src/index.ts +++ b/packages/main/src/index.ts @@ -32,6 +32,9 @@ app ipcMain.handle('peerbit:add-release', async (_event, releaseData: ReleaseData) => lensService?.addRelease(releaseData), ); + ipcMain.handle('peerbit:edit-release', async (_event, releaseData: ReleaseData) => + lensService?.editRelease(releaseData), + ); ipcMain.handle('peerbit:get-release', async (_event, id: string) => lensService?.getRelease({ id }), ); diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts index b132d101..466931a6 100644 --- a/packages/preload/src/index.ts +++ b/packages/preload/src/index.ts @@ -4,7 +4,7 @@ import { contextBridge, ipcRenderer } from 'electron'; import { isLinux, isMac, isWindows, platform} from './so'; -import type { ReleaseData, HashResponse, Release } from '@riffcc/lens-sdk'; +import type { ReleaseData, HashResponse, Release, EditInput } from '@riffcc/lens-sdk'; contextBridge.exposeInMainWorld('osInfo', { isMac, @@ -25,6 +25,7 @@ contextBridge.exposeInMainWorld('electronLensService', { getPeerId: (): Promise => ipcRenderer.invoke('peerbit:get-peer-id'), dial: (address: string): Promise => ipcRenderer.invoke('peerbit:dial', address), addRelease: (releaseData: ReleaseData): Promise => ipcRenderer.invoke('peerbit:add-release', releaseData), + editRelease: (releaseData: EditInput): Promise => ipcRenderer.invoke('peerbit:edit-release', releaseData), getRelease: (id: string): Promise => ipcRenderer.invoke('peerbit:get-release', id), getLatestReleases: (size?: number): Promise => ipcRenderer.invoke('peerbit:get-latest-releases', size), }); diff --git a/packages/renderer/src/App.vue b/packages/renderer/src/App.vue index 729db198..e23e5297 100644 --- a/packages/renderer/src/App.vue +++ b/packages/renderer/src/App.vue @@ -1,6 +1,6 @@ @@ -32,28 +42,44 @@ import { onKeyStroke } from '@vueuse/core'; import { ref, watchEffect, onMounted } from 'vue'; -import appBar from '/@/components/layout/appBar.vue'; import appFooter from '/@/components/layout/appFooter.vue'; +import GamepadNavBar from '/@/components/layout/gamepadNavBar.vue'; import audioPlayer from '/@/components/releases/audioPlayer.vue'; import videoPlayer from '/@/components/releases/videoPlayer.vue'; +import GamepadHints from '/@/components/gamepad/gamepadHints.vue'; +import StartMenu from '/@/components/misc/startMenu.vue'; import { useAudioAlbum } from '/@/composables/audioAlbum'; import { useFloatingVideo } from '/@/composables/floatingVideo'; import { useShowDefederation } from '/@/composables/showDefed'; import { useLensInitialization } from '/@/composables/lensInitialization'; +import { useGamepad } from '/@/composables/useGamepad'; +import { useGamepadNavigation } from '/@/composables/useGamepadNavigation'; import { useGetReleasesQuery, useGetFeaturedReleasesQuery, useContentCategoriesQuery } from './plugins/lensService'; +import { useGlobalPlayback } from '/@/composables/globalPlayback'; +import { useInputMethod } from '/@/composables/useInputMethod'; const { showDefederation } = useShowDefederation(); const { activeTrack } = useAudioAlbum(); -const { floatingVideoSource } = useFloatingVideo(); +const { floatingVideoSource, floatingVideoRelease } = useFloatingVideo(); const { isLensReady, initLensService } = useLensInitialization(); +const { gamepadState, onButtonPress } = useGamepad(); +const { showCursor } = useGamepadNavigation(); +const { currentInputMethod } = useInputMethod(); + +const showStartMenu = ref(false); + const MAGIC_KEY = 'magicmagic'; const yetToType = ref(MAGIC_KEY); onKeyStroke(e => { - if (!yetToType.value.length) return; + if (!yetToType.value.length) { + // Reset for next time + yetToType.value = MAGIC_KEY; + return; + } if (e.key === yetToType.value[0]) { yetToType.value = yetToType.value.slice(1); } else { @@ -62,27 +88,28 @@ onKeyStroke(e => { }); watchEffect(() => { - if (!yetToType.value.length) showDefederation.value = true; -}); - -const CURTAIN_KEY = 'curtain'; -const yetToTypeCurtain = ref(CURTAIN_KEY); - -onKeyStroke(e => { - if (!yetToTypeCurtain.value.length) return; - if (e.key === yetToTypeCurtain.value[0]) { - yetToTypeCurtain.value = yetToTypeCurtain.value.slice(1); - } else { - yetToTypeCurtain.value = CURTAIN_KEY; + if (!yetToType.value.length) { + // Toggle defederation view when magic key is fully typed + showDefederation.value = !showDefederation.value; } }); -watchEffect(() => { - if (!yetToTypeCurtain.value.length) showDefederation.value = false; -}); - onMounted(async () => { initLensService(); + + // Setup gamepad controls + onButtonPress('start', () => { + showStartMenu.value = true; + }); + + // Setup L3/R3 for play/pause + const { globalTogglePlay } = useGlobalPlayback(); + onButtonPress('leftStickButton', () => { + globalTogglePlay(); + }); + onButtonPress('rightStickButton', () => { + globalTogglePlay(); + }); }); @@ -97,4 +124,181 @@ const { data: featuredReleases } = useGetFeaturedReleasesQuery({ const { data: contentCategories } = useContentCategoriesQuery({ enabled: isLensReady, }); + +// Delay showing spinner for 100ms to make app feel faster +const showSpinner = ref(false); +let spinnerTimer: ReturnType | null = null; + +onMounted(() => { + spinnerTimer = setTimeout(() => { + if (!isLensReady.value && !releases.value && !featuredReleases.value && !contentCategories.value) { + showSpinner.value = true; + } + }, 100); +}); + +// Clear timer if data loads quickly +watchEffect(() => { + if (isLensReady.value || releases.value || featuredReleases.value || contentCategories.value) { + if (spinnerTimer) { + clearTimeout(spinnerTimer); + spinnerTimer = null; + } + showSpinner.value = false; + } +}); + + diff --git a/packages/renderer/src/components/account/accountMenu.vue b/packages/renderer/src/components/account/accountMenu.vue index ef56372d..0623433b 100644 --- a/packages/renderer/src/components/account/accountMenu.vue +++ b/packages/renderer/src/components/account/accountMenu.vue @@ -3,9 +3,9 @@ diff --git a/packages/renderer/src/components/admin/accessManagement.vue b/packages/renderer/src/components/admin/accessManagement.vue index 4a4475f5..6c2911b2 100644 --- a/packages/renderer/src/components/admin/accessManagement.vue +++ b/packages/renderer/src/components/admin/accessManagement.vue @@ -58,13 +58,13 @@ > +