Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • 1.4.1-upgrade-release
  • 1121-download
  • 1218-smartplaylist_backend
  • 1373-login-form-move-reset-your-password-link
  • 1381-progress-bars
  • 1481
  • 1518-update-django-allauth
  • 1645
  • 1675-widget-improperly-configured-missing-resource-id
  • 1675-widget-improperly-configured-missing-resource-id-2
  • 1704-required-props-are-not-always-passed
  • 1716-add-frontend-tests-again
  • 1749-smtp-uri-configuration
  • 1930-first-upload-in-a-batch-always-fails
  • 1976-update-documentation-links-in-readme-files
  • 2054-player-layout
  • 2063-funkwhale-connection-interrupted-every-so-often-requires-network-reset-page-refresh
  • 2091-iii-6-improve-visuals-layout
  • 2151-refused-to-load-spa-manifest-json-2
  • 2154-add-to-playlist-pop-up-hidden-by-now-playing-screen
  • 2155-can-t-see-the-episode-list-of-a-podcast-as-an-anonymous-user-with-anonymous-access-enabled
  • 2156-add-management-command-to-change-file-ref-for-in-place-imported-files-to-s3
  • 2192-clear-queue-bug-when-random-shuffle-is-enabled
  • 2205-channel-page-pagination-link-dont-working
  • 2215-custom-logger-does-not-work-at-all-with-webkit-and-blink-based-browsers
  • 2228-troi-real-world-review
  • 2274-implement-new-upload-api
  • 2303-allow-users-to-own-tagged-items
  • 2395-far-right-filter
  • 2405-front-buttont-trigger-third-party-hook
  • 2408-troi-create-missing-tracks
  • 2416-revert-library-drop
  • 2422-trigger-libraries-follow-on-user-follow
  • 2429-fix-popover-auto-close
  • 2448-complete-tags
  • 2452-fetch-third-party-metadata
  • 2469-Fix-search-bar-in-ManageUploads
  • 2476-deep-upload-links
  • 2490-experiment-use-rstore
  • 2490-experimental-use-simple-data-store
  • 2490-fix-search-modal
  • 2490-search-modal
  • 2501-fix-compatibility-with-older-browsers
  • 2502-drop-uno-and-jquery
  • 2533-allow-followers-in-user-activiy-privacy-level
  • 2539-drop-ansible-installation-method-in-favor-of-docker
  • 2560-default-modal-width
  • 623-test
  • 653-enable-starting-embedded-player-at-a-specific-position-in-track
  • activitypub-overview
  • album-sliders
  • arne/2091-improve-visuals
  • back-option-for-edits
  • chore/2406-compose-modularity-scope
  • develop
  • develop-password-reset
  • env-file-cleanup
  • feat/2091-improve-visuals
  • feature/2481-vui-translations
  • fix-amd64-docker-build-gfortran
  • fix-front-node-version
  • fix-gitpod
  • fix-plugins-dev-setup
  • fix-rate-limit-serializer
  • fix-schema-channel-metadata-choices
  • flupsi/2803-improve-visuals
  • flupsi/2804-new-upload-process
  • funkwhale-fix_pwa_manifest
  • funkwhale-petitminion-2136-bug-fix-prune-skipped-upload
  • funkwhale-ui-buttons
  • georg/add-typescript
  • gitpod/test-1866
  • global-button-experiment
  • global-buttons
  • juniorjpdj/pkg-repo
  • manage-py-reference
  • merge-review
  • minimal-python-version
  • petitminion-develop-patch-84496
  • pin-mutagen-to-1.46
  • pipenv
  • plugins
  • plugins-v2
  • plugins-v3
  • pre-release/1.3.0
  • prune_skipped_uploads_docs
  • refactor/homepage
  • renovate/front-all-dependencies
  • renovate/front-major-all-dependencies
  • schema-updates
  • small-gitpod-improvements
  • spectacular_schema
  • stable
  • tempArne
  • ui-buttons
  • update-frontend-dependencies
  • upload-process-spec
  • user-concept-docs
  • v2-artists
  • vite-ws-ssl-compatible
  • 0.1
  • 0.10
  • 0.11
  • 0.12
  • 0.13
  • 0.14
  • 0.14.1
  • 0.14.2
  • 0.15
  • 0.16
  • 0.16.1
  • 0.16.2
  • 0.16.3
  • 0.17
  • 0.18
  • 0.18.1
  • 0.18.2
  • 0.18.3
  • 0.19.0
  • 0.19.0-rc1
  • 0.19.0-rc2
  • 0.19.1
  • 0.2
  • 0.2.1
  • 0.2.2
  • 0.2.3
  • 0.2.4
  • 0.2.5
  • 0.2.6
  • 0.20.0
  • 0.20.0-rc1
  • 0.20.1
  • 0.21
  • 0.21-rc1
  • 0.21-rc2
  • 0.21.1
  • 0.21.2
  • 0.3
  • 0.3.1
  • 0.3.2
  • 0.3.3
  • 0.3.4
  • 0.3.5
  • 0.4
  • 0.5
  • 0.5.1
  • 0.5.2
  • 0.5.3
  • 0.5.4
  • 0.6
  • 0.6.1
  • 0.7
  • 0.8
  • 0.9
  • 0.9.1
  • 1.0
  • 1.0-rc1
  • 1.0.1
  • 1.1
  • 1.1-rc1
  • 1.1-rc2
  • 1.1.1
  • 1.1.2
  • 1.1.3
  • 1.1.4
  • 1.2.0
  • 1.2.0-rc1
  • 1.2.0-rc2
  • 1.2.0-testing
  • 1.2.0-testing2
  • 1.2.0-testing3
  • 1.2.0-testing4
  • 1.2.1
  • 1.2.10
  • 1.2.2
  • 1.2.3
  • 1.2.4
  • 1.2.5
  • 1.2.6
  • 1.2.6-1
  • 1.2.7
  • 1.2.8
  • 1.2.9
  • 1.3.0
  • 1.3.0-rc1
  • 1.3.0-rc2
  • 1.3.0-rc3
  • 1.3.0-rc4
  • 1.3.0-rc5
  • 1.3.0-rc6
  • 1.3.1
  • 1.3.2
  • 1.3.3
  • 1.3.4
  • 1.4.0
  • 1.4.0-rc1
  • 1.4.0-rc2
  • 1.4.1
  • 2.0.0-alpha.1
  • 2.0.0-alpha.2
200 results

Target

Select target project
  • funkwhale/funkwhale
  • Luclu7/funkwhale
  • mbothorel/funkwhale
  • EorlBruder/funkwhale
  • tcit/funkwhale
  • JocelynDelalande/funkwhale
  • eneiluj/funkwhale
  • reg/funkwhale
  • ButterflyOfFire/funkwhale
  • m4sk1n/funkwhale
  • wxcafe/funkwhale
  • andybalaam/funkwhale
  • jcgruenhage/funkwhale
  • pblayo/funkwhale
  • joshuaboniface/funkwhale
  • n3ddy/funkwhale
  • gegeweb/funkwhale
  • tohojo/funkwhale
  • emillumine/funkwhale
  • Te-k/funkwhale
  • asaintgenis/funkwhale
  • anoadragon453/funkwhale
  • Sakada/funkwhale
  • ilianaw/funkwhale
  • l4p1n/funkwhale
  • pnizet/funkwhale
  • dante383/funkwhale
  • interfect/funkwhale
  • akhardya/funkwhale
  • svfusion/funkwhale
  • noplanman/funkwhale
  • nykopol/funkwhale
  • roipoussiere/funkwhale
  • Von/funkwhale
  • aurieh/funkwhale
  • icaria36/funkwhale
  • floreal/funkwhale
  • paulwalko/funkwhale
  • comradekingu/funkwhale
  • FurryJulie/funkwhale
  • Legolars99/funkwhale
  • Vierkantor/funkwhale
  • zachhats/funkwhale
  • heyjake/funkwhale
  • sn0w/funkwhale
  • jvoisin/funkwhale
  • gordon/funkwhale
  • Alexander/funkwhale
  • bignose/funkwhale
  • qasim.ali/funkwhale
  • fakegit/funkwhale
  • Kxze/funkwhale
  • stenstad/funkwhale
  • creak/funkwhale
  • Kaze/funkwhale
  • Tixie/funkwhale
  • IISergII/funkwhale
  • lfuelling/funkwhale
  • nhaddag/funkwhale
  • yoasif/funkwhale
  • ifischer/funkwhale
  • keslerm/funkwhale
  • flupe/funkwhale
  • petitminion/funkwhale
  • ariasuni/funkwhale
  • ollie/funkwhale
  • ngaumont/funkwhale
  • techknowlogick/funkwhale
  • Shleeble/funkwhale
  • theflyingfrog/funkwhale
  • jonatron/funkwhale
  • neobrain/funkwhale
  • eorn/funkwhale
  • KokaKiwi/funkwhale
  • u1-liquid/funkwhale
  • marzzzello/funkwhale
  • sirenwatcher/funkwhale
  • newer027/funkwhale
  • codl/funkwhale
  • Zwordi/funkwhale
  • gisforgabriel/funkwhale
  • iuriatan/funkwhale
  • simon/funkwhale
  • bheesham/funkwhale
  • zeoses/funkwhale
  • accraze/funkwhale
  • meliurwen/funkwhale
  • divadsn/funkwhale
  • Etua/funkwhale
  • sdrik/funkwhale
  • Soran/funkwhale
  • kuba-orlik/funkwhale
  • cristianvogel/funkwhale
  • Forceu/funkwhale
  • jeff/funkwhale
  • der_scheibenhacker/funkwhale
  • owlnical/funkwhale
  • jovuit/funkwhale
  • SilverFox15/funkwhale
  • phw/funkwhale
  • mayhem/funkwhale
  • sridhar/funkwhale
  • stromlin/funkwhale
  • rrrnld/funkwhale
  • nitaibezerra/funkwhale
  • jaller94/funkwhale
  • pcouy/funkwhale
  • eduxstad/funkwhale
  • codingHahn/funkwhale
  • captain/funkwhale
  • polyedre/funkwhale
  • leishenailong/funkwhale
  • ccritter/funkwhale
  • lnceballosz/funkwhale
  • fpiesche/funkwhale
  • Fanyx/funkwhale
  • markusblogde/funkwhale
  • Firobe/funkwhale
  • devilcius/funkwhale
  • freaktechnik/funkwhale
  • blopware/funkwhale
  • cone/funkwhale
  • thanksd/funkwhale
  • vachan-maker/funkwhale
  • bbenti/funkwhale
  • tarator/funkwhale
  • prplecake/funkwhale
  • DMarzal/funkwhale
  • lullis/funkwhale
  • hanacgr/funkwhale
  • albjeremias/funkwhale
  • xeruf/funkwhale
  • llelite/funkwhale
  • RoiArthurB/funkwhale
  • cloo/funkwhale
  • nztvar/funkwhale
  • Keunes/funkwhale
  • petitminion/funkwhale-petitminion
  • m-idler/funkwhale
  • SkyLeite/funkwhale
140 results
Select Git revision
  • 1121-download
  • 1218-smartplaylist_backend
  • 1288-user-me-can-be-created-but-cannot-be-edited
  • 1381-progress-bars
  • 1392-update-actor-cache-task
  • 1434-update-pyld
  • 1481
  • 1515-update-click
  • 1518-update-django-allauth
  • 1645
  • 1674-recently-added-radio-repair
  • 1711-ping_remote_instance
  • 1714-resolve-timeouts-on-domain-nodeinfo-fetch
  • 1717-stop-player-when-stop-radio
  • 1798-bulk-fetch-actor-data
  • 2010-fix_i18n_globally
  • 2136-bug-fix-prune-skipped-upload
  • 2275-quality-filter-backend
  • 2322-troi-frontend
  • 623-test
  • 639-ThirdPartyStream-poc
  • 653-enable-starting-embedded-player-at-a-specific-position-in-track
  • 762-domain_follow
  • album-sliders
  • back-option-for-edits
  • develop
  • feat/2091-improve-visuals
  • funkwhale-activityPub-overview
  • generate-swagger
  • master
  • pipenv
  • plugins
  • plugins-v2
  • plugins-v3
  • poetry
  • renovate/configure
  • spec-domain-follow
  • spec_test_for_issue_1
  • stable
  • test_typesens
  • testbranch
  • troi-recommendation-system-with-typenses
  • update-boto3
  • update-frontend-dependencies
  • update-uvicorn
  • 0.1
  • 0.10
  • 0.11
  • 0.12
  • 0.13
  • 0.14
  • 0.14.1
  • 0.14.2
  • 0.15
  • 0.16
  • 0.16.1
  • 0.16.2
  • 0.16.3
  • 0.17
  • 0.18
  • 0.18.1
  • 0.18.2
  • 0.18.3
  • 0.19.0
  • 0.19.0-rc1
  • 0.19.0-rc2
  • 0.19.1
  • 0.2
  • 0.2.1
  • 0.2.2
  • 0.2.3
  • 0.2.4
  • 0.2.5
  • 0.2.6
  • 0.20.0
  • 0.20.0-rc1
  • 0.20.1
  • 0.21
  • 0.21-rc1
  • 0.21-rc2
  • 0.21.1
  • 0.21.2
  • 0.3
  • 0.3.1
  • 0.3.2
  • 0.3.3
  • 0.3.4
  • 0.3.5
  • 0.4
  • 0.5
  • 0.5.1
  • 0.5.2
  • 0.5.3
  • 0.5.4
  • 0.6
  • 0.6.1
  • 0.7
  • 0.8
  • 0.9
  • 0.9.1
  • 1.0
  • 1.0-rc1
  • 1.0.1
  • 1.1
  • 1.1-rc1
  • 1.1-rc2
  • 1.1.1
  • 1.1.2
  • 1.1.3
  • 1.1.4
  • 1.2.0
  • 1.2.0-rc1
  • 1.2.0-rc2
  • 1.2.0-testing
  • 1.2.0-testing2
  • 1.2.0-testing3
  • 1.2.0-testing4
  • 1.2.1
118 results
Show changes
Showing
with 23837 additions and 53 deletions
......@@ -43,7 +43,7 @@ export const isPlaying = ref(false)
// Use Player
export const usePlayer = createGlobalState(() => {
const { currentSound } = useTracks()
const { playNext } = useQueue()
const { playNext, playPrevious } = useQueue()
const pauseReason = ref(PauseReason.UserInput)
......@@ -228,6 +228,52 @@ export const usePlayer = createGlobalState(() => {
watch(currentIndex, stopErrorTimeout)
whenever(errored, startErrorTimeout)
// Mobile controls and lockscreen cover art
const updateMediaSession = () => {
if ('mediaSession' in navigator && currentTrack.value) {
navigator.mediaSession.metadata = new MediaMetadata({
title: currentTrack.value.title,
artist: currentTrack.value.artistCredit?.map(ac => ac.credit).join(', ') || 'Unknown Artist',
album: currentTrack.value.albumTitle || 'Unknown Album',
artwork: [
{ src: currentTrack.value.coverUrl, sizes: '1200x1200', type: 'image/jpeg' }
]
})
navigator.mediaSession.setActionHandler('play', () => {
isPlaying.value = true
})
navigator.mediaSession.setActionHandler('pause', () => {
isPlaying.value = false
})
navigator.mediaSession.setActionHandler('previoustrack', () => {
playPrevious()
})
navigator.mediaSession.setActionHandler('nexttrack', () => {
playNext()
})
navigator.mediaSession.setActionHandler('seekbackward', (details) => {
seekBy(details.seekOffset || -10)
})
navigator.mediaSession.setActionHandler('seekforward', (details) => {
seekBy(details.seekOffset || 10)
})
}
}
watch(currentTrack, () => {
updateMediaSession()
})
watch(isPlaying, () => {
navigator.mediaSession.playbackState = isPlaying.value ? 'playing' : 'paused'
})
return {
initializeFirstTrack,
isPlaying,
......
......@@ -111,6 +111,9 @@ export const useQueue = createGlobalState(() => {
const { uploads } = await axios.get(`tracks/${track.id}/`)
.then(response => response.data as Track, () => ({ uploads: [] as Upload[] }))
// TODO: Either make `track` a writable ref or implement the client/cache model
// See Issue: https://dev.funkwhale.audio/funkwhale/funkwhale/-/issues/2437
// @ts-expect-error `track` is read-only
track.uploads = uploads
}
......@@ -123,11 +126,11 @@ export const useQueue = createGlobalState(() => {
artistId: (track.artist_credit && track.artist_credit[0] && track.artist_credit[0].artist.id) ?? -1,
albumId: track.album?.id ?? -1,
coverUrl: (
(track.cover?.urls)
|| (track.album?.cover?.urls)
|| ((track.artist_credit && track.artist_credit[0] && track.artist_credit[0].artist && track.artist_credit[0].artist.cover?.urls))
|| {}
)?.original ?? new URL('../../assets/audio/default-cover.png', import.meta.url).href,
track.cover?.urls.original
|| track.album?.cover?.urls.original
|| track.artist_credit?.[0]?.artist.cover?.urls.original
|| new URL('../../assets/audio/default-cover.png', import.meta.url).href
).toString(),
sources: track.uploads.map(upload => ({
uuid: upload.uuid,
duration: upload.duration,
......
......@@ -27,6 +27,14 @@ const soundCache = new LRUCache<number, Sound>({
dispose: (sound) => sound.dispose()
})
// used to make soundCache reactive
const soundCacheVersion = ref(0)
function setSoundCache(trackId: number, sound: Sound) {
soundCache.set(trackId, sound)
soundCacheVersion.value++ // bump to trigger reactivity
}
const currentTrack = ref<QueueTrack>()
export const fetchTrackSources = async (id: number): Promise<QueueTrackSource[]> => {
......@@ -139,7 +147,7 @@ export const useTracks = createGlobalState(() => {
}
// Add track to the sound cache and remove from the promise cache
soundCache.set(track.id, sound)
setSoundCache(track.id, sound)
soundPromises.delete(track.id)
return sound
......@@ -224,7 +232,12 @@ export const useTracks = createGlobalState(() => {
})
})
const currentSound = computed(() => soundCache.get(currentTrack.value?.id ?? -1))
const currentSound = computed(() => {
soundCacheVersion.value //trigger reactivity
const trackId = currentTrack.value?.id ?? -1
const sound = soundCache.get(trackId)
return sound
})
const clearCache = () => {
return soundCache.clear()
......
import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
import type { components } from '~/generated/types'
import type { ContentFilter } from '~/store/moderation'
import { useCurrentElement } from '@vueuse/core'
import { computed, markRaw, ref } from 'vue'
import { i18n } from '~/init/locale'
import { useStore } from '~/store'
......@@ -9,19 +9,18 @@ import { useStore } from '~/store'
import { usePlayer } from '~/composables/audio/player'
import { useQueue } from '~/composables/audio/queue'
import jQuery from 'jquery'
import axios from 'axios'
export interface PlayOptionsProps {
isPlayable?: boolean
tracks?: Track[]
track?: Track | null
artist?: Artist | null
artist?: Artist | components["schemas"]["SimpleChannelArtist"] | components['schemas']['ArtistWithAlbums'] | null
album?: Album | null
playlist?: Playlist | null
library?: Library | null
channel?: Channel | null
account?: Actor | null
account?: Actor | components['schemas']['APIActor'] | null
}
export default (props: PlayOptionsProps) => {
......@@ -37,8 +36,12 @@ export default (props: PlayOptionsProps) => {
if (props.track) {
return props.track.uploads?.length > 0
} else if (props.artist) {
// TODO: Find out how to get tracks, album from Artist
/*
return props.artist.tracks_count > 0
|| props.artist?.albums?.some((album) => album.is_playable === true)
*/
} else if (props.tracks) {
return props.tracks?.some((track) => (track.uploads?.length ?? 0) > 0)
}
......@@ -126,7 +129,7 @@ export default (props: PlayOptionsProps) => {
tracks.push(response.data as Track)
}
} else if (props.playlist) {
const response = await axios.get(`playlists/${props.playlist.id}/tracks/`)
const response = await axios.get(`playlists/${props.playlist.uuid}/tracks/`)
const playlistTracks = (response.data.results as Array<{ track: Track }>).map(({ track }) => track as Track)
const artistIds = store.getters['moderation/artistFilters']().map((filter: ContentFilter) => filter.target.id)
......@@ -150,18 +153,15 @@ export default (props: PlayOptionsProps) => {
return tracks.filter(track => track.uploads?.length).map(markRaw)
}
const el = useCurrentElement()
const enqueue = async () => {
jQuery(el.value).find('.ui.dropdown').dropdown('hide')
// const el = useCurrentElement()
const enqueue = async () => {
const tracks = await getPlayableTracks()
await addToQueue(...tracks)
addMessage(tracks)
}
const enqueueNext = async (next = false) => {
jQuery(el.value).find('.ui.dropdown').dropdown('hide')
const tracks = await getPlayableTracks()
const wasEmpty = queue.value.length === 0
......@@ -177,9 +177,6 @@ export default (props: PlayOptionsProps) => {
const replacePlay = async (index?: number) => {
await clear()
jQuery(el.value).find('.ui.dropdown').dropdown('hide')
const tracksToPlay = await getPlayableTracks()
await addToQueue(...tracksToPlay)
......@@ -203,6 +200,29 @@ export default (props: PlayOptionsProps) => {
return replacePlay(index)
}
const requestPlaylistUploadsAccess = async (playlist: Playlist) => {
const libraryUrl = playlist.library;
if (!libraryUrl) {
throw new Error("Playlist library URL is missing.");
}
const libResponse = await axios.get(libraryUrl);
const id = libResponse.data?.id || libResponse.data?.results?.id;
if (!id) {
throw new Error("Library id not found in response.");
}
const fetchResponse = await axios.post('federation/fetches',
{ object: id }
);
const response = await axios.post(
'federation/follows/library',
{ target: fetchResponse.data.object.uuid }
);
return response;
};
return {
playable,
filterableArtist,
......@@ -211,6 +231,7 @@ export default (props: PlayOptionsProps) => {
enqueueNext,
replacePlay,
activateTrack,
isLoading
isLoading,
requestPlaylistUploadsAccess
}
}
import type { KeysOfUnion } from 'type-fest'
import type { HTMLAttributes } from 'vue'
export type DefaultProps =
| { default?: true }
export type Default = KeysOfUnion<DefaultProps>
export type ColorProps =
| { primary?: true }
| { secondary?: true }
| { destructive?: true }
export type Color = KeysOfUnion<ColorProps>
export type PastelProps =
| { red?: true }
| { blue?: true }
| { purple?: true }
| { green?: true }
| { yellow?: true }
export type Pastel = KeysOfUnion<PastelProps>
export type VariantProps =
| { solid?: true }
| { outline?: true }
| { ghost?: true }
export type Variant = KeysOfUnion<VariantProps>
export type InteractiveProps =
| { interactive?: true }
export type Interactive = KeysOfUnion<DefaultProps>
export type RaisedProps =
| { raised?: true }
export type Raised = KeysOfUnion<RaisedProps>
/* Props to Classes */
export type Props =
(DefaultProps | ColorProps | PastelProps) & VariantProps & InteractiveProps & RaisedProps
export type Key =
KeysOfUnion<Props>
// You can only have one color
const conflicts: Set<Key>[] = [
new Set(['default', 'primary', 'secondary', 'destructive', 'red', 'blue', 'purple', 'green', 'yellow']),
new Set(['solid', 'outline', 'ghost'])
]
const classes = {
default: 'default',
primary: 'primary',
secondary: 'secondary',
destructive: 'destructive',
red: 'red',
blue: 'blue',
purple: 'purple',
green: 'green',
yellow: 'yellow',
outline: 'outline',
ghost: 'ghost',
solid: 'solid',
raised: 'raised',
interactive: 'interactive'
} satisfies Record<Key, string>
const getPrecedence = (searchKey:Key | string) =>
Object.entries(classes).findIndex(([key, _]) => key === searchKey)
/**
* @param props A superset of `{ Key? : true }`
* @returns the number of actually applied classes (if there are no defaults)
*/
export const isNoColors = (props: Partial<Props>) =>
!color(props)().class
const merge = (classes: string[]) => (attributes: HTMLAttributes = {}) =>
classes.length === 0
? attributes
: ({
...attributes,
class: classes.join(' ') + ('class' in attributes ? attributes.class + ' ' : '')
})
/**
* Add color classes to your component.
* Color classes are defined in `colors.scss`. Make sure to implement the correct style there!
*
* (1) Add a subset of `& (DefaultProps | ColorProps | PastelProps) & VariantProps & InteractiveProps & RaisedProps` to your `Props` type
* (2) Call `v-bind="color(props)"` on your component template
* (3) Now your component accepts color props such as `secondary outline raised`.
*
* Composable with `width`, `color`, `alignment`, etc.
*
* @param props Your component's props (or ...rest props if you have destructured them already)
* @param defaults These props are applied immediately and can be overridden by the user
* @returns a function from the resulting attributes the corresponding `class` object
*/
export const color = (props: Partial<Props>, defaults?: Key[]) =>
merge(
Object.entries(props)
.sort(([a, _], [b, __]) => getPrecedence(a) - getPrecedence(b))
.reduce(
(acc, [key, value]) =>
value && key in classes
? acc.filter(accKey => !conflicts.find(set => set.has(accKey) && set.has(key as Key)))
.concat([key as Key])
: acc
,
defaults || []
)
)
type ColorSelector =
`${Color | Pastel | Default}${'' | ` ${Variant}${'' | ' interactive'}${'' | ' raised'}`}`
// Convenience function for applying default colors. Prefer using `color`
export const setColors = (color: ColorSelector) =>
({ class: color })
import type { PrivacyLevel, ImportStatus } from '~/types'
import type { ImportStatus } from '~/types'
import type { components } from '~/generated/types'
import type { ScopeId } from '~/composables/auth/useScopes'
import { i18n } from '~/init/locale'
const { t } = i18n.global
// TODO: Remove this unnecessary abstraction (unless it has a purpose)
export default () => ({
fields: {
privacy_level: {
......@@ -13,13 +16,15 @@ export default () => ({
choices: {
me: t('composables.locale.useSharedLabels.fields.privacyLevel.choices.private'),
instance: t('composables.locale.useSharedLabels.fields.privacyLevel.choices.instance'),
followers: t('composables.locale.useSharedLabels.fields.privacyLevel.choices.followers'),
everyone: t('composables.locale.useSharedLabels.fields.privacyLevel.choices.public')
} as Record<PrivacyLevel, string>,
} satisfies Record<components['schemas']['PrivacyLevelEnum'], string>,
shortChoices: {
me: t('composables.locale.useSharedLabels.fields.privacyLevel.shortChoices.private'),
instance: t('composables.locale.useSharedLabels.fields.privacyLevel.shortChoices.instance'),
followers: t('composables.locale.useSharedLabels.fields.privacyLevel.choices.followers'),
everyone: t('composables.locale.useSharedLabels.fields.privacyLevel.shortChoices.public')
} as Record<PrivacyLevel, string>
} satisfies Record<components['schemas']['PrivacyLevelEnum'], string>
},
import_status: {
label: t('composables.locale.useSharedLabels.fields.importStatus.label'),
......
......@@ -16,7 +16,7 @@ export interface EditableConfigField extends ConfigField {
id: EditObjectType
}
export type EditObject = (Partial<Artist> | Partial<Album> | Partial<Track>) & { attributed_to: Actor }
export type EditObject = (Partial<Artist> | Partial<Album> | Partial<Track>) & { attributed_to?: Actor }
export type EditObjectType = 'artist' | 'album' | 'track'
type Configs = Record<EditObjectType, { fields: (EditableConfigField|ConfigField)[] }>
......@@ -79,6 +79,7 @@ export default (): Configs => {
description,
{
id: 'release_date',
// TODO: Change type to date and offer date select input in form
type: 'text',
required: false,
label: t('composables.moderation.useEditConfigs.album.releaseDate'),
......
import type { Track, Artist, Album, Playlist, Library, Channel, Actor, ArtistCredit } from '~/types'
import type { components } from '~/generated/types'
import { i18n } from '~/init/locale'
......@@ -8,11 +9,11 @@ const { t } = i18n.global
interface Objects {
track?: Track | null
album?: Album | null
artist?: Artist | null
album?: Album | components['schemas']['TrackAlbum'] | null
artist?: Artist | components['schemas']['ArtistWithAlbums'] | components["schemas"]["SimpleChannelArtist"] | null
artistCredit?: ArtistCredit[] | null
playlist?: Playlist | null
account?: Actor | null
account?: Actor | components['schemas']['APIActor'] | null
library?: Library | null
channel?: Channel | null
}
......@@ -111,7 +112,7 @@ const getReportableObjects = ({ track, album, artist, artistCredit, playlist, ac
label: t('composables.moderation.useReport.playlist.label'),
target: {
type: 'playlist',
id: playlist.id,
uuid: playlist.uuid,
label: playlist.name,
_obj: playlist,
typeLabel: t('composables.moderation.useReport.playlist.typeLabel')
......
......@@ -6,9 +6,17 @@ import { useRoute } from 'vue-router'
import { watch, readonly } from 'vue'
export interface OrderingProps {
/** Custom name for storing ordering preferences */
orderingConfigName?: RouteRecordName
}
/**
* Synchronizes ordering state between URL query parameters and localStorage,
* with automatic persistence across page reloads.
*
* @param props - Configuration options
* @returns Ordering state and update handlers
*/
export default <T extends string = string>(props: OrderingProps) => {
const route = useRoute()
......@@ -24,6 +32,8 @@ export default <T extends string = string>(props: OrderingProps) => {
paginateBy: route.meta.paginateBy ?? 50
}))
// TODO: I would like to replace the prefix `pref` with something that feels more solid.
// The implicit renaming here somewhat obscures the author's intention...
const {
orderingDirection: prefOrderingDirection,
paginateBy: prefPaginateBy,
......@@ -32,7 +42,15 @@ export default <T extends string = string>(props: OrderingProps) => {
replaceRef: false
})
const normalizeDirection = (direction: string) => direction === '+' ? '' : '-'
/**
* Normalizes ordering direction for URL query parameters.
* @param direction - '+' or some other string
* @returns Empty string for ascending, '-' for descending
*/
const normalizeDirection = (direction: string) =>
direction === '+'
? ''
: '-'
const queryOrdering = useRouteQuery(
'ordering',
......@@ -60,7 +78,12 @@ export default <T extends string = string>(props: OrderingProps) => {
prefOrdering.value = ordering.replace(/^[+-]/, '')
}, { immediate: true })
// NOTE: We're using `flush: 'post'` to make sure that the `onOrderingUpdate` callback is called after all updates are done
/**
* Registers a callback to execute when ordering preferences change.
* @param fn - Callback function to execute on updates
* @returns Watcher cleanup function
* NOTE: We're using `flush: 'post'` to make sure that the `onOrderingUpdate` callback is called after all updates are done
*/
const onOrderingUpdate = (fn: () => void) => watch(preferences, fn, {
flush: 'post'
})
......
......@@ -2,12 +2,18 @@ import { useRouteQuery } from '@vueuse/router'
import { syncRef } from '@vueuse/core'
import { ref } from 'vue'
/**
* Syncs ref `page` with the homonymous route query parameter
* @returns {Ref<number>} The page number
*/
export default () => {
const pageQuery = useRouteQuery<string>('page', '1')
const page = ref()
const page = ref<number>()
syncRef(pageQuery, page, {
transform: {
ltr: (left) => +left,
// TODO: Why toString?
// @ts-expect-error string vs. number
rtl: (right) => right.toString()
}
})
......
......@@ -5,11 +5,32 @@ import { refWithControl } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
/**
* Configuration options for the smart search composable.
*/
export interface SmartSearchProps {
/** Initial query string to populate the search */
defaultQuery?: string
/** Whether to sync search state with the browser URL */
updateUrl?: boolean
}
/**
* Enables structured search queries like "status:pending category:music" while maintaining
* bidirectional sync between raw query strings and parsed tokens. Supports URL synchronization
* and programmatic search manipulation.
*
* @param props - Configuration options for the search behavior
* @returns Object containing search methods and reactive query state
*
* @example
* ```ts
* const search = useSmartSearch({ updateUrl: true })
* search.addSearchToken('status', 'pending')
* search.addSearchToken('category', 'music')
* // Generates query: "status:pending category:music"
* ```
*/
export default (props: SmartSearchProps) => {
const query = refWithControl(props.defaultQuery ?? '')
const tokens = ref([] as Token[])
......@@ -19,6 +40,13 @@ export default (props: SmartSearchProps) => {
}, { immediate: true })
const updateHandlers = new Set<() => void>()
/**
* Calls a function whenever search tokens have changed.
*
* @param fn - Callback function
* @returns Cleanup function to unregister the callback
*/
const onSearch = (fn: () => void) => {
updateHandlers.add(fn)
return () => updateHandlers.delete(fn)
......@@ -40,6 +68,13 @@ export default (props: SmartSearchProps) => {
// this.fetchData()
}, { deep: true })
/**
* Retrieves the value of a specific search token by its field key.
*
* @param key - The field name to search for (e.g., 'status', 'category')
* @param fallback - Default value to return if the token is not found
* @returns The token's value if found, otherwise the fallback value
*/
const getTokenValue = (key: string, fallback: string) => {
const matching = tokens.value.find(token => {
return token.field === key
......@@ -48,6 +83,16 @@ export default (props: SmartSearchProps) => {
return matching?.value ?? fallback
}
/**
* Adds or updates a search token with the specified field and value.
*
* If the value is empty, removes all tokens with the given field.
* If tokens with the field already exist, updates their values.
* If no tokens with the field exist, creates a new token.
*
* @param key - The field name for the search token
* @param value - The value for the search token (empty string removes the token)
*/
const addSearchToken = (key: string, value: string) => {
if (value === '') {
tokens.value = tokens.value.filter(token => {
......
......@@ -20,7 +20,7 @@ useEventListener(window, 'keydown', (event) => {
if (!event.key) return
const target = event.target as HTMLElement
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return
current.add(event.key.toLowerCase())
......
import {
type MaybeRefOrGetter,
createGlobalState,
toValue,
useWindowSize
} from '@vueuse/core'
import { computed } from 'vue'
const MOBILE_WIDTH = 640
export const useScreenSize = createGlobalState(() =>
useWindowSize({ includeScrollbar: false })
)
export const isMobileView = (
width: MaybeRefOrGetter<number> = useScreenSize().width
) =>
computed(() => (toValue(width) ?? Number.POSITIVE_INFINITY) <= MOBILE_WIDTH)
import type { Notification } from '~/types'
import type { Notification, Track } from '~/types'
import store from '~/store'
import { tryOnScopeDispose } from '@vueuse/core'
......@@ -50,7 +50,8 @@ function useWebSocketHandler (eventName: 'mutation.created', handler: (event: Pe
function useWebSocketHandler (eventName: 'mutation.updated', handler: (event: PendingReviewEdits) => void): stopFn
function useWebSocketHandler (eventName: 'import.status_updated', handler: (event: ImportStatusWS) => void): stopFn
function useWebSocketHandler (eventName: 'user_request.created', handler: (event: PendingReviewRequests) => void): stopFn
function useWebSocketHandler (eventName: 'Listen', handler: (event: ListenWS) => void): stopFn
function useWebSocketHandler (eventName: 'Listen', handler: (event: unknown) => void): stopFn
function useWebSocketHandler (eventName: 'playlist.track_updated', handler: (event: {track: Track}) => void): stopFn
function useWebSocketHandler (eventName: string, handler: (event: any) => void): stopFn {
const id = `${+new Date() + Math.random()}`
......
import type { Entries, KeysOfUnion } from 'type-fest'
import type { HTMLAttributes } from 'vue'
export type WidthProps =
| { minContent?: true }
| { iconWidth?: true }
| { tiny?: true }
| { buttonWidth?: true }
| { small?: true }
| { medium?: true }
| { larger?: true }
| { auto?: true }
| { full?: true }
| { grow?: true }
| { width?: string }
| { square?: true }
| { squareSmall?: true }
| { lowHeight?: true }
| { normalHeight?: true }
| { circular? : true }
export type Key = KeysOfUnion<WidthProps>
const widths = {
minContent: 'width: min-content; flex-grow: 0;',
iconWidth: 'width: 40px;',
tiny: 'width: 124px; --grid-column: span 2;',
buttonWidth: 'width: 136px; --grid-column: span 2; flex-grow: 0; min-width: min-content;',
small: 'width: 202px; --grid-column: span 3;',
medium: 'width: 280px; --grid-column: span 4;',
larger: 'width: 358px; --grid-column: span 5;',
auto: 'width: auto;',
full: 'width: auto; --grid-column: 1 / -1; place-self: stretch;',
grow: 'flex-grow: 1;',
circular: 'border-radius: 100%;',
width: (w: string) => `width: ${w}; flex-grow:0;`
} as const
const sizes = {
squareSmall: 'height: 40px; width: 40px; padding: 4px; justify-content: center;',
square: 'height: 48px; width: 48px; justify-content: center;',
lowHeight: 'height: 40px;',
normalHeight: 'height: 48px;'
} as const
const styles = {
...widths, ...sizes
} as const satisfies Record<Key, string | ((w: string) => string)>
// The `lint:tsc` script more errors here than the language server is happy.
// TODO: Fix this Issue: https://dev.funkwhale.audio/funkwhale/funkwhale/-/issues/2437
const getStyle = (props: Partial<WidthProps>) => (key: Key):string =>
key in props
? typeof styles[key] === 'function'
// @ts-expect-error Typescript is hard. Make the typescript compiler understand `key in props`
? styles[key](props[key])
: styles[key] as string
: ''
// All keys are exclusive
const conflicts: Set<Key>[] = [
new Set(Object.keys(widths) as Key[]),
new Set(Object.keys(sizes) as Key[])
]
const merge = (rules: string[]) => (attributes: HTMLAttributes = {}) =>
rules.length === 0
? attributes
: ({
...attributes,
style: rules.join(' ') + ('style' in attributes ? ' ' + attributes.style : '')
})
/**
* Add a width style to your component.
* Widths are designed to work both in a page-grid context and in a flex or normal context.
*
* (1) Add `& WidthProps` to your `Props` type
* (2) Call `v-bind="width(props)"` on your component template
* (3) Now your component accepts width props such as `small`, `medium`, `stretch`.
*
* @param props Your component's props (or ...rest props if you have destructured them already)
* @param defaults These props are applied immediately and can be overridden by the user
* @param attributes Optional: To compose width, color, alignment, etc.
* @returns the corresponding `{ style }` object
*/
export const width = <TProps extends Partial<WidthProps>>(
props: TProps,
defaults: Key[] = []
) => merge(
(Object.entries(props) as Entries<TProps>).reduce(
(acc, [key, value]) =>
value && key in styles
? acc.filter(accKey => !conflicts.find(set => set.has(accKey) && set.has(key)))
.concat([key])
: acc
,
defaults
).map(getStyle(props))
)
../../../api/funkwhale_api/common/schema.yml
\ No newline at end of file
Source diff could not be displayed: it is too large. Options to address this: view the blob.
......@@ -5,7 +5,6 @@ import { parseAPIErrors } from '~/utils'
import { i18n } from './locale'
import createAuthRefreshInterceptor from 'axios-auth-refresh'
import moment from 'moment'
import axios from 'axios'
import useLogger from '~/composables/useLogger'
......@@ -61,23 +60,31 @@ export const install: InitModule = ({ store, router }) => {
break
case 429: {
let message
let message = ''
// TODO: Find out if the following fields are still relevant
const rateLimitStatus: RateLimitStatus = {
limit: error.response?.headers['x-ratelimit-limit'],
scope: error.response?.headers['x-ratelimit-scope'],
// scope: error.response?.headers['x-ratelimit-scope'],
remaining: error.response?.headers['x-ratelimit-remaining'],
duration: error.response?.headers['x-ratelimit-duration'],
availableSeconds: parseInt(error.response?.headers['retry-after'] ?? '60'),
// availableSeconds: parseInt(error.response?.headers['retry-after'] ?? '60'),
reset: error.response?.headers['x-ratelimit-reset'],
resetSeconds: error.response?.headers['x-ratelimit-resetseconds']
// resetSeconds: error.response?.headers['x-ratelimit-resetseconds']
// The following fields were missing:
id: '',
rate: '',
description: '',
available: 0,
available_seconds: 0,
reset_seconds: 0
}
if (rateLimitStatus.availableSeconds) {
const tryAgain = moment().add(rateLimitStatus.availableSeconds, 's').toNow(true)
message = t('init.axios.rateLimitDelay', { delay: tryAgain })
} else {
message = t('init.axios.rateLimitLater')
}
// if (rateLimitStatus.availableSeconds) {
// const tryAgain = moment().add(rateLimitStatus.availableSeconds, 's').toNow(true)
// message = t('init.axios.rateLimitDelay', { delay: tryAgain })
// }
error.backendErrors.push(message)
error.isHandled = true
......
import type { InitModule } from '~/types'
import { setupDropdown } from '~/utils/fomantic'
export const install: InitModule = ({ app, store }) => {
app.directive('title', function (el, binding) {
store.commit('ui/pageTitle', binding.value)
})
app.directive('dropdown', (element) => setupDropdown(element))
}
import type { NodeInfo } from '~/store/instance'
import type { components } from '~/generated/types'
import type { InitModule } from '~/types'
import { whenever } from '@vueuse/core'
......@@ -15,7 +15,7 @@ export const install: InitModule = async ({ store, router }) => {
const fetchNodeInfo = async () => {
try {
const [{ data }] = await Promise.all([
axios.get<NodeInfo>('instance/nodeinfo/2.1/'),
axios.get<components['schemas']['NodeInfo21']>('instance/nodeinfo/2.1/'),
store.dispatch('instance/fetchSettings')
])
......