This commit is contained in:
austinried 2025-10-16 20:04:15 +09:00
parent b0bb26f84b
commit 7f592c7db1
166 changed files with 237 additions and 34002 deletions

View File

@ -1,4 +0,0 @@
TEST_SERVER_NAME=Subsonic Demo
TEST_SERVER_URL=http://demo.subsonic.org
TEST_SERVER_USERNAME=guest
TEST_SERVER_PASSWORD=guest

View File

@ -1,4 +0,0 @@
{
"flutterSdkVersion": "3.7.11",
"flavors": {}
}

11
.gitignore vendored
View File

@ -5,9 +5,11 @@
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
@ -25,12 +27,11 @@ migrate_working_dir/
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
@ -43,7 +44,5 @@ app.*.map.json
/android/app/profile
/android/app/release
/.env
*.sqlite*
/.fvm/flutter_sdk
*.keystore
# VSCode
.vscode/settings.json

View File

@ -1,11 +1,11 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled.
# This file should be version controlled and should not be manually edited.
version:
revision: 9944297138845a94256f1cf37beb88ff9a8e811a
channel: stable
revision: "9f455d2486bcb28cad87b062475f42edc959f636"
channel: "stable"
project_type: app
@ -13,11 +13,11 @@ project_type: app
migration:
platforms:
- platform: root
create_revision: 9944297138845a94256f1cf37beb88ff9a8e811a
base_revision: 9944297138845a94256f1cf37beb88ff9a8e811a
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
- platform: android
create_revision: 9944297138845a94256f1cf37beb88ff9a8e811a
base_revision: 9944297138845a94256f1cf37beb88ff9a8e811a
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
# User provided section

View File

@ -1,500 +0,0 @@
{
"ar": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
],
"ca": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
],
"cs": [
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesPlaylistCount",
"resourcesSongCount",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsMusicName",
"settingsMusicOptionsScrobbleDescriptionOff",
"settingsMusicOptionsScrobbleDescriptionOn",
"settingsMusicOptionsScrobbleTitle",
"settingsNetworkOptionsMaxBufferTitle",
"settingsNetworkOptionsMinBufferTitle",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn"
],
"da": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"actionsStar",
"actionsUnstar",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterStarred",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAdded",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByFrequentlyPlayed",
"resourcesSortByRecentlyPlayed",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsMusicOptionsScrobbleDescriptionOff",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName",
"settingsServersOptionsForcePlaintextPasswordDescriptionOff",
"settingsServersOptionsForcePlaintextPasswordDescriptionOn",
"settingsServersOptionsForcePlaintextPasswordTitle"
],
"de": [
"settingsAboutShareLogs",
"settingsAboutChooseLog"
],
"es": [
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
],
"fr": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
],
"it": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
],
"ja": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"actionsStar",
"actionsUnstar",
"controlsShuffle",
"messagesNothingHere",
"resourcesAlbumActionsPlay",
"resourcesAlbumActionsView",
"resourcesAlbumCount",
"resourcesAlbumListsSort",
"resourcesArtistActionsView",
"resourcesArtistCount",
"resourcesArtistListsSort",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterGenre",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistActionsPlay",
"resourcesPlaylistCount",
"resourcesQueueName",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAdded",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByArtist",
"resourcesSortByName",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"resourcesSortByYear",
"searchHeaderTitle",
"searchMoreResults",
"searchNowPlayingContext",
"settingsAboutActionsLicenses",
"settingsAboutActionsSupport",
"settingsAboutName",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsAboutVersion",
"settingsMusicOptionsScrobbleDescriptionOff",
"settingsMusicOptionsScrobbleDescriptionOn",
"settingsMusicOptionsScrobbleTitle",
"settingsNetworkOptionsMaxBitrateMobileTitle",
"settingsNetworkOptionsMaxBitrateWifiTitle",
"settingsNetworkOptionsMaxBufferTitle",
"settingsNetworkOptionsMinBufferTitle",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsNetworkValuesKbps",
"settingsNetworkValuesSeconds",
"settingsNetworkValuesUnlimitedKbps",
"settingsResetActionsClearImageCache",
"settingsServersActionsAdd",
"settingsServersActionsDelete",
"settingsServersActionsEdit",
"settingsServersActionsSave",
"settingsServersActionsTestConnection",
"settingsServersFieldsAddress",
"settingsServersFieldsName",
"settingsServersFieldsPassword",
"settingsServersFieldsUsername",
"settingsServersMessagesConnectionFailed",
"settingsServersMessagesConnectionOk",
"settingsServersOptionsForcePlaintextPasswordDescriptionOff",
"settingsServersOptionsForcePlaintextPasswordDescriptionOn",
"settingsServersOptionsForcePlaintextPasswordTitle"
],
"nb": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
],
"pa": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
],
"pl": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
],
"pt": [
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterOwner",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbumCount",
"resourcesSortByUpdated",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
],
"tr": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
],
"vi": [
"actionsCancel",
"actionsDelete",
"actionsDownload",
"actionsDownloadCancel",
"actionsDownloadDelete",
"actionsOk",
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesFilterAlbum",
"resourcesFilterArtist",
"resourcesFilterOwner",
"resourcesFilterYear",
"resourcesPlaylistCount",
"resourcesSongCount",
"resourcesSongListDeleteAllContent",
"resourcesSongListDeleteAllTitle",
"resourcesSortByAlbum",
"resourcesSortByAlbumCount",
"resourcesSortByTitle",
"resourcesSortByUpdated",
"settingsAboutActionsSupport",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
],
"zh": [
"controlsShuffle",
"resourcesAlbumCount",
"resourcesArtistCount",
"resourcesPlaylistCount",
"resourcesSongCount",
"settingsAboutShareLogs",
"settingsAboutChooseLog",
"settingsNetworkOptionsOfflineMode",
"settingsNetworkOptionsOfflineModeOff",
"settingsNetworkOptionsOfflineModeOn",
"settingsNetworkOptionsStreamFormat",
"settingsNetworkOptionsStreamFormatServerDefault",
"settingsServersFieldsName"
]
}

View File

@ -1,19 +1 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
prefer_relative_imports: true
analyzer:
exclude:
- '**.freezed.dart'
- '**.g.dart'
- '**.gr.dart'
plugins:
# broken currently and may not get fixed
# https://github.com/simolus3/drift/issues/2342
# - drift
# also broken but only recently reported
# https://github.com/rrousselGit/riverpod/issues/2180
# - custom_lint

3
android/.gitignore vendored
View File

@ -5,9 +5,10 @@ gradle-wrapper.jar
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@ -1,88 +0,0 @@
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}
def flutterRoot = localProperties.getProperty('flutter.sdk')
if (flutterRoot == null) {
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
compileSdkVersion flutter.compileSdkVersion
ndkVersion flutter.ndkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "com.subtracks2"
// You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
minSdkVersion 19
targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
storePassword keystoreProperties['storePassword']
}
}
buildTypes {
release {
if (project.hasProperty("signRelease")) {
signingConfig signingConfigs.release
} else {
signingConfig signingConfigs.debug
}
}
}
}
flutter {
source '../..'
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
}

View File

@ -0,0 +1,41 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.subtracks2"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions { jvmTarget = JavaVersion.VERSION_11.toString() }
defaultConfig {
// TODO: Specify your own unique Application ID
// (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.subtracks2"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter { source = "../.." }

View File

@ -1,5 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.subtracks2">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.

View File

@ -1,72 +1,45 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.subtracks2">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="subtracks"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
android:label="subtracks2"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name="com.ryanheise.audioservice.AudioServiceActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<service
android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true"
tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<service
android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true"
tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true"
tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- <meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="true" /> -->
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
<uses-permission android:name="android.permission.INTERNET" />
<!-- audio_service -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@ -1,25 +0,0 @@
// Generated file.
//
// If you wish to remove Flutter's multidex support, delete this entire file.
//
// Modifications to this file should be done in a copy under a different name
// as this file may be regenerated.
package io.flutter.app;
import android.app.Application;
import android.content.Context;
import androidx.annotation.CallSuper;
import androidx.multidex.MultiDex;
/**
* Extension of {@link android.app.Application}, adding multidex support.
*/
public class FlutterMultiDexApplication extends Application {
@Override
@CallSuper
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
MultiDex.install(this);
}
}

View File

@ -2,5 +2,4 @@ package com.subtracks2
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity() {
}
class MainActivity : FlutterActivity()

View File

@ -1,30 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="299.84"
android:viewportHeight="219.51"
android:tint="#FFFFFF">
<group android:scaleX="0.92"
android:scaleY="0.6735232"
android:translateX="11.9936"
android:translateY="35.83246">
<path
android:pathData="m23.84,-0c-12.76,0 -23.44,13 -23.65,25.12 -0.42,79.03 0,160.73 0,170.4 0,12.59 9.8,23.99 23.41,23.99l253.58,0c11.4,0 22.66,-12.38 22.66,-22.79L299.84,24.12c0,-10.8 -10.24,-23.81 -21.84,-23.81 -11.6,0 -254.16,-0.31 -254.16,-0.31zM39.05,25.28c0,0 217.71,0.71 223.67,0.71 6.07,0 12.17,6.83 12.17,12.06l0,142.25c0,8.97 -6.97,14.63 -12.48,14.64l-33.42,0.07c-5.32,0.01 -9.93,-7.19 -11.35,-10.61 -1.89,-4.55 -11.08,-25.75 -12.1,-28.1 -0.6,-1.38 -2.9,-2.83 -5.89,-2.83L98.33,153.45c-2.68,0 -4.89,2.87 -5.67,4.71 -1.12,2.66 -9.02,21.39 -11.63,27.57 -2.73,6.46 -4.37,9.06 -11.47,9.06l-30.97,0c-7.44,0 -13.69,-7.17 -13.67,-15.18l0.28,-139.51c0,-6.88 5.54,-14.83 13.85,-14.83z"
android:strokeWidth="0.9"
android:fillColor="#ffffff"
android:fillType="nonZero"
android:strokeColor="#00000000"/>
<path
android:pathData="M90.09,95.03m-32.1,0a32.42,32.1 90,1 1,64.21 0a32.42,32.1 90,1 1,-64.21 0"
android:strokeWidth="0.86"
android:fillColor="#ffffff"
android:fillType="nonZero"
android:strokeColor="#00000000"/>
<path
android:pathData="M209.94,95.03m-32.1,0a32.42,32.1 90,1 1,64.21 0a32.42,32.1 90,1 1,-64.21 0"
android:strokeWidth="0.86"
android:fillColor="#ffffff"
android:fillType="nonZero"
android:strokeColor="#00000000"/>
</group>
</vector>

View File

@ -1,29 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="299.84"
android:viewportHeight="219.51"
android:tint="#333333"
android:alpha="0.6">
<group android:scaleY="0.7320905"
android:translateY="29.404413">
<path
android:pathData="m23.84,-0c-12.76,0 -23.44,13 -23.65,25.12 -0.42,79.03 0,160.73 0,170.4 0,12.59 9.8,23.99 23.41,23.99l253.58,0c11.4,0 22.66,-12.38 22.66,-22.79L299.84,24.12c0,-10.8 -10.24,-23.81 -21.84,-23.81 -11.6,0 -254.16,-0.31 -254.16,-0.31zM39.05,25.28c0,0 217.71,0.71 223.67,0.71 6.07,0 12.17,6.83 12.17,12.06l0,142.25c0,8.97 -6.97,14.63 -12.48,14.64l-33.42,0.07c-5.32,0.01 -9.93,-7.19 -11.35,-10.61 -1.89,-4.55 -11.08,-25.75 -12.1,-28.1 -0.6,-1.38 -2.9,-2.83 -5.89,-2.83L98.33,153.45c-2.68,0 -4.89,2.87 -5.67,4.71 -1.12,2.66 -9.02,21.39 -11.63,27.57 -2.73,6.46 -4.37,9.06 -11.47,9.06l-30.97,0c-7.44,0 -13.69,-7.17 -13.67,-15.18l0.28,-139.51c0,-6.88 5.54,-14.83 13.85,-14.83z"
android:strokeWidth="0.9"
android:fillColor="#ffffff"
android:fillType="nonZero"
android:strokeColor="#00000000"/>
<path
android:pathData="M90.09,95.03m-32.1,0a32.42,32.1 90,1 1,64.21 0a32.42,32.1 90,1 1,-64.21 0"
android:strokeWidth="0.86"
android:fillColor="#ffffff"
android:fillType="nonZero"
android:strokeColor="#00000000"/>
<path
android:pathData="M209.94,95.03m-32.1,0a32.42,32.1 90,1 1,64.21 0a32.42,32.1 90,1 1,-64.21 0"
android:strokeWidth="0.86"
android:fillColor="#ffffff"
android:fillType="nonZero"
android:strokeColor="#00000000"/>
</group>
</vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 441 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 575 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 925 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 921 B

View File

@ -1,29 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="299.84"
android:viewportHeight="219.51">
<group android:scaleX="0.43"
android:scaleY="0.3147989"
android:translateX="85.4544"
android:translateY="75.20425">
<path
android:pathData="m23.84,-0c-12.76,0 -23.44,13 -23.65,25.12 -0.42,79.03 0,160.73 0,170.4 0,12.59 9.8,23.99 23.41,23.99l253.58,0c11.4,0 22.66,-12.38 22.66,-22.79L299.84,24.12c0,-10.8 -10.24,-23.81 -21.84,-23.81 -11.6,0 -254.16,-0.31 -254.16,-0.31zM39.05,25.28c0,0 217.71,0.71 223.67,0.71 6.07,0 12.17,6.83 12.17,12.06l0,142.25c0,8.97 -6.97,14.63 -12.48,14.64l-33.42,0.07c-5.32,0.01 -9.93,-7.19 -11.35,-10.61 -1.89,-4.55 -11.08,-25.75 -12.1,-28.1 -0.6,-1.38 -2.9,-2.83 -5.89,-2.83L98.33,153.45c-2.68,0 -4.89,2.87 -5.67,4.71 -1.12,2.66 -9.02,21.39 -11.63,27.57 -2.73,6.46 -4.37,9.06 -11.47,9.06l-30.97,0c-7.44,0 -13.69,-7.17 -13.67,-15.18l0.28,-139.51c0,-6.88 5.54,-14.83 13.85,-14.83z"
android:strokeWidth="0.9"
android:fillColor="#f4d9ff"
android:fillType="nonZero"
android:strokeColor="#00000000"/>
<path
android:pathData="M90.09,95.03m-32.1,0a32.42,32.1 90,1 1,64.21 0a32.42,32.1 90,1 1,-64.21 0"
android:strokeWidth="0.86"
android:fillColor="#f4d9ff"
android:fillType="nonZero"
android:strokeColor="#00000000"/>
<path
android:pathData="M209.94,95.03m-32.1,0a32.42,32.1 90,1 1,64.21 0a32.42,32.1 90,1 1,-64.21 0"
android:strokeWidth="0.86"
android:fillColor="#f4d9ff"
android:fillType="nonZero"
android:strokeColor="#00000000"/>
</group>
</vector>

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources
xmlns:tools="http://schemas.android.com/tools"
tools:keep="@drawable/*" />

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#6A1B9A</color>
</resources>

View File

@ -1,5 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.subtracks2">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.

View File

@ -1,31 +0,0 @@
buildscript {
ext.kotlin_version = '1.7.10'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.2.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(':app')
}
task clean(type: Delete) {
delete rootProject.buildDir
}

24
android/build.gradle.kts Normal file
View File

@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@ -1,3 +1,3 @@
org.gradle.jvmargs=-Xmx1536M
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true

0
android/gradle/wrapper/gradle-wrapper.jar vendored Executable file → Normal file
View File

View File

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip

0
android/gradlew.bat vendored Executable file → Normal file
View File

View File

@ -1,11 +0,0 @@
include ':app'
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
def properties = new Properties()
assert localPropertiesFile.exists()
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"

View File

@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.9.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}
include(":app")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 96 960 960" width="24"><path d="M290 896q-19 0-31-15t-7-33l28-112h-89q-20 0-32-15.5t-7-34.5q3-14 14-22t25-8h109l40-160H231q-20 0-32-15.5t-7-34.5q3-14 14-22t25-8h129l33-131q3-13 13-21t24-8q19 0 31 15t7 33l-28 112h160l33-131q3-13 13-21t24-8q19 0 31 15t7 33l-28 112h89q20 0 32 15.5t7 34.5q-3 14-14 22t-25 8H660l-40 160h109q20 0 32 15.5t7 34.5q-3 14-14 22t-25 8H600l-33 131q-3 13-13 21t-24 8q-19 0-31-15t-7-33l28-112H360l-33 131q-3 13-13 21t-24 8Zm90-240h160l40-160H420l-40 160Z"/></svg>

Before

Width:  |  Height:  |  Size: 546 B

View File

@ -1,11 +0,0 @@
targets:
$default:
builders:
drift_dev:
options:
sql:
dialect: sqlite
options:
version: "3.38"
modules:
- fts5

View File

@ -1,4 +0,0 @@
arb-dir: lib/l10n
template-arb-file: app_en.arb
nullable-getter: false
untranslated-messages-file: .untranslated-messages.json

View File

@ -1,85 +0,0 @@
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../database/database.dart';
import '../services/settings_service.dart';
import '../state/init.dart';
import '../state/theme.dart';
part 'app.g.dart';
class MyApp extends HookConsumerWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final init = ref.watch(initProvider);
return init.when(
data: (_) => const App(),
error: (e, s) => Directionality(
textDirection: TextDirection.ltr,
child: Container(
color: Colors.red[900],
child: Column(children: [
const SizedBox(height: 100),
Text(e.toString()),
Text(s.toString()),
]),
),
),
loading: () => const CircularProgressIndicator(),
);
}
}
@Riverpod(keepAlive: true)
class LastPath extends _$LastPath {
@override
String build() {
return '/settings';
}
Future<void> init() async {
final db = ref.read(databaseProvider);
final lastBottomNav = await db.getLastBottomNavState().getSingleOrNull();
final lastLibrary = await db.getLastLibraryState().getSingleOrNull();
if (lastBottomNav == null || lastLibrary == null) return;
// TODO: replace this with a proper first-time setup flow
final hasActiveSource = ref.read(settingsServiceProvider.select(
(value) => value.activeSource != null,
));
if (!hasActiveSource) return;
state = lastBottomNav.tab == 'library'
? '/library/${lastLibrary.tab}'
: '/${lastBottomNav.tab}';
}
}
class App extends HookConsumerWidget {
const App({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final appRouter = ref.watch(routerProvider);
final base = ref.watch(baseThemeProvider);
final lastPath = ref.watch(lastPathProvider);
return MaterialApp.router(
theme: base.theme,
debugShowCheckedModeBanner: false,
routerDelegate: appRouter.delegate(
initialDeepLink: lastPath,
),
routeInformationParser: appRouter.defaultRouteParser(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: [...AppLocalizations.supportedLocales]
..moveToTheFront(const Locale('en')),
);
}
}

View File

@ -1,23 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$lastPathHash() => r'25ba5df6bd984fcce011eec40a12fb74627a790a';
/// See also [LastPath].
@ProviderFor(LastPath)
final lastPathProvider = NotifierProvider<LastPath, String>.internal(
LastPath.new,
name: r'lastPathProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$lastPathHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$LastPath = Notifier<String>;
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions

View File

@ -1,140 +0,0 @@
// ignore_for_file: use_key_in_widget_constructors
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'pages/artist_page.dart';
import 'pages/bottom_nav_page.dart';
import 'pages/browse_page.dart';
import 'pages/library_albums_page.dart';
import 'pages/library_artists_page.dart';
import 'pages/library_page.dart';
import 'pages/library_playlists_page.dart';
import 'pages/library_songs_page.dart';
import 'pages/now_playing_page.dart';
import 'pages/search_page.dart';
import 'pages/settings_page.dart';
import 'pages/songs_page.dart';
import 'pages/source_page.dart';
part 'app_router.gr.dart';
const kCustomTransitionBuilder = TransitionsBuilders.slideRightWithFade;
const kCustomTransitionDuration = 160;
const itemRoutes = [
CustomRoute(
path: 'album/:id',
page: AlbumSongsPage,
transitionsBuilder: kCustomTransitionBuilder,
durationInMilliseconds: kCustomTransitionDuration,
reverseDurationInMilliseconds: kCustomTransitionDuration,
),
CustomRoute(
path: 'artist/:id',
page: ArtistPage,
transitionsBuilder: kCustomTransitionBuilder,
durationInMilliseconds: kCustomTransitionDuration,
reverseDurationInMilliseconds: kCustomTransitionDuration,
),
CustomRoute(
path: 'playlist/:id',
page: PlaylistSongsPage,
transitionsBuilder: kCustomTransitionBuilder,
durationInMilliseconds: kCustomTransitionDuration,
reverseDurationInMilliseconds: kCustomTransitionDuration,
),
CustomRoute(
path: 'genre/:genre',
page: GenreSongsPage,
transitionsBuilder: kCustomTransitionBuilder,
durationInMilliseconds: kCustomTransitionDuration,
reverseDurationInMilliseconds: kCustomTransitionDuration,
),
];
class EmptyRouterPage extends AutoRouter {
const EmptyRouterPage({Key? key})
: super(
key: key,
inheritNavigatorObservers: false,
);
}
@MaterialAutoRouter(
replaceInRouteName: 'Page,Route',
routes: <AutoRoute>[
AutoRoute(path: '/', name: 'RootRouter', page: EmptyRouterPage, children: [
AutoRoute(path: '', page: BottomNavTabsPage, children: [
AutoRoute(
path: 'library',
name: 'LibraryRouter',
page: EmptyRouterPage,
children: [
AutoRoute(path: '', page: LibraryTabsPage, children: [
AutoRoute(path: 'albums', page: LibraryAlbumsPage),
AutoRoute(path: 'artists', page: LibraryArtistsPage),
AutoRoute(path: 'playlists', page: LibraryPlaylistsPage),
AutoRoute(path: 'songs', page: LibrarySongsPage),
]),
...itemRoutes,
]),
AutoRoute(
path: 'browse',
name: 'BrowseRouter',
page: EmptyRouterPage,
children: [
AutoRoute(path: '', page: BrowsePage),
...itemRoutes,
]),
AutoRoute(
path: 'search',
name: 'SearchRouter',
page: EmptyRouterPage,
children: [
AutoRoute(path: '', page: SearchPage),
...itemRoutes,
]),
AutoRoute(
path: 'settings',
name: 'SettingsRouter',
page: EmptyRouterPage,
children: [
AutoRoute(path: '', page: SettingsPage),
CustomRoute(
path: 'source/:id',
page: SourcePage,
transitionsBuilder: kCustomTransitionBuilder,
durationInMilliseconds: kCustomTransitionDuration,
reverseDurationInMilliseconds: kCustomTransitionDuration,
),
]),
]),
]),
CustomRoute(
path: '/now-playing',
page: NowPlayingPage,
transitionsBuilder: TransitionsBuilders.slideBottom,
durationInMilliseconds: 200,
reverseDurationInMilliseconds: 160,
),
],
)
class AppRouter extends _$AppRouter {}
class TabObserver extends AutoRouterObserver {
final StreamController<String> _controller = StreamController.broadcast();
Stream<String> get path => _controller.stream;
@override
void didInitTabRoute(TabPageRoute route, TabPageRoute? previousRoute) {
_controller.add(route.path);
}
@override
void didChangeTabRoute(TabPageRoute route, TabPageRoute previousRoute) {
_controller.add(route.path);
}
}

View File

@ -1,720 +0,0 @@
// **************************************************************************
// AutoRouteGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// **************************************************************************
// AutoRouteGenerator
// **************************************************************************
//
// ignore_for_file: type=lint
part of 'app_router.dart';
class _$AppRouter extends RootStackRouter {
_$AppRouter([GlobalKey<NavigatorState>? navigatorKey]) : super(navigatorKey);
@override
final Map<String, PageFactory> pagesMap = {
RootRouter.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const EmptyRouterPage(),
);
},
NowPlayingRoute.name: (routeData) {
return CustomPage<dynamic>(
routeData: routeData,
child: const NowPlayingPage(),
transitionsBuilder: TransitionsBuilders.slideBottom,
durationInMilliseconds: 200,
reverseDurationInMilliseconds: 160,
opaque: true,
barrierDismissible: false,
);
},
BottomNavTabsRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const BottomNavTabsPage(),
);
},
LibraryRouter.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const EmptyRouterPage(),
);
},
BrowseRouter.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const EmptyRouterPage(),
);
},
SearchRouter.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const EmptyRouterPage(),
);
},
SettingsRouter.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const EmptyRouterPage(),
);
},
LibraryTabsRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const LibraryTabsPage(),
);
},
AlbumSongsRoute.name: (routeData) {
final pathParams = routeData.inheritedPathParams;
final args = routeData.argsAs<AlbumSongsRouteArgs>(
orElse: () => AlbumSongsRouteArgs(id: pathParams.getString('id')));
return CustomPage<dynamic>(
routeData: routeData,
child: AlbumSongsPage(
key: args.key,
id: args.id,
),
transitionsBuilder: TransitionsBuilders.slideRightWithFade,
durationInMilliseconds: 160,
reverseDurationInMilliseconds: 160,
opaque: true,
barrierDismissible: false,
);
},
ArtistRoute.name: (routeData) {
final pathParams = routeData.inheritedPathParams;
final args = routeData.argsAs<ArtistRouteArgs>(
orElse: () => ArtistRouteArgs(id: pathParams.getString('id')));
return CustomPage<dynamic>(
routeData: routeData,
child: ArtistPage(
key: args.key,
id: args.id,
),
transitionsBuilder: TransitionsBuilders.slideRightWithFade,
durationInMilliseconds: 160,
reverseDurationInMilliseconds: 160,
opaque: true,
barrierDismissible: false,
);
},
PlaylistSongsRoute.name: (routeData) {
final pathParams = routeData.inheritedPathParams;
final args = routeData.argsAs<PlaylistSongsRouteArgs>(
orElse: () => PlaylistSongsRouteArgs(id: pathParams.getString('id')));
return CustomPage<dynamic>(
routeData: routeData,
child: PlaylistSongsPage(
key: args.key,
id: args.id,
),
transitionsBuilder: TransitionsBuilders.slideRightWithFade,
durationInMilliseconds: 160,
reverseDurationInMilliseconds: 160,
opaque: true,
barrierDismissible: false,
);
},
GenreSongsRoute.name: (routeData) {
final pathParams = routeData.inheritedPathParams;
final args = routeData.argsAs<GenreSongsRouteArgs>(
orElse: () =>
GenreSongsRouteArgs(genre: pathParams.getString('genre')));
return CustomPage<dynamic>(
routeData: routeData,
child: GenreSongsPage(
key: args.key,
genre: args.genre,
),
transitionsBuilder: TransitionsBuilders.slideRightWithFade,
durationInMilliseconds: 160,
reverseDurationInMilliseconds: 160,
opaque: true,
barrierDismissible: false,
);
},
LibraryAlbumsRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const LibraryAlbumsPage(),
);
},
LibraryArtistsRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const LibraryArtistsPage(),
);
},
LibraryPlaylistsRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const LibraryPlaylistsPage(),
);
},
LibrarySongsRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const LibrarySongsPage(),
);
},
BrowseRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const BrowsePage(),
);
},
SearchRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const SearchPage(),
);
},
SettingsRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const SettingsPage(),
);
},
SourceRoute.name: (routeData) {
final pathParams = routeData.inheritedPathParams;
final args = routeData.argsAs<SourceRouteArgs>(
orElse: () => SourceRouteArgs(id: pathParams.optInt('id')));
return CustomPage<dynamic>(
routeData: routeData,
child: SourcePage(
key: args.key,
id: args.id,
),
transitionsBuilder: TransitionsBuilders.slideRightWithFade,
durationInMilliseconds: 160,
reverseDurationInMilliseconds: 160,
opaque: true,
barrierDismissible: false,
);
},
};
@override
List<RouteConfig> get routes => [
RouteConfig(
RootRouter.name,
path: '/',
children: [
RouteConfig(
BottomNavTabsRoute.name,
path: '',
parent: RootRouter.name,
children: [
RouteConfig(
LibraryRouter.name,
path: 'library',
parent: BottomNavTabsRoute.name,
children: [
RouteConfig(
LibraryTabsRoute.name,
path: '',
parent: LibraryRouter.name,
children: [
RouteConfig(
LibraryAlbumsRoute.name,
path: 'albums',
parent: LibraryTabsRoute.name,
),
RouteConfig(
LibraryArtistsRoute.name,
path: 'artists',
parent: LibraryTabsRoute.name,
),
RouteConfig(
LibraryPlaylistsRoute.name,
path: 'playlists',
parent: LibraryTabsRoute.name,
),
RouteConfig(
LibrarySongsRoute.name,
path: 'songs',
parent: LibraryTabsRoute.name,
),
],
),
RouteConfig(
AlbumSongsRoute.name,
path: 'album/:id',
parent: LibraryRouter.name,
),
RouteConfig(
ArtistRoute.name,
path: 'artist/:id',
parent: LibraryRouter.name,
),
RouteConfig(
PlaylistSongsRoute.name,
path: 'playlist/:id',
parent: LibraryRouter.name,
),
RouteConfig(
GenreSongsRoute.name,
path: 'genre/:genre',
parent: LibraryRouter.name,
),
],
),
RouteConfig(
BrowseRouter.name,
path: 'browse',
parent: BottomNavTabsRoute.name,
children: [
RouteConfig(
BrowseRoute.name,
path: '',
parent: BrowseRouter.name,
),
RouteConfig(
AlbumSongsRoute.name,
path: 'album/:id',
parent: BrowseRouter.name,
),
RouteConfig(
ArtistRoute.name,
path: 'artist/:id',
parent: BrowseRouter.name,
),
RouteConfig(
PlaylistSongsRoute.name,
path: 'playlist/:id',
parent: BrowseRouter.name,
),
RouteConfig(
GenreSongsRoute.name,
path: 'genre/:genre',
parent: BrowseRouter.name,
),
],
),
RouteConfig(
SearchRouter.name,
path: 'search',
parent: BottomNavTabsRoute.name,
children: [
RouteConfig(
SearchRoute.name,
path: '',
parent: SearchRouter.name,
),
RouteConfig(
AlbumSongsRoute.name,
path: 'album/:id',
parent: SearchRouter.name,
),
RouteConfig(
ArtistRoute.name,
path: 'artist/:id',
parent: SearchRouter.name,
),
RouteConfig(
PlaylistSongsRoute.name,
path: 'playlist/:id',
parent: SearchRouter.name,
),
RouteConfig(
GenreSongsRoute.name,
path: 'genre/:genre',
parent: SearchRouter.name,
),
],
),
RouteConfig(
SettingsRouter.name,
path: 'settings',
parent: BottomNavTabsRoute.name,
children: [
RouteConfig(
SettingsRoute.name,
path: '',
parent: SettingsRouter.name,
),
RouteConfig(
SourceRoute.name,
path: 'source/:id',
parent: SettingsRouter.name,
),
],
),
],
)
],
),
RouteConfig(
NowPlayingRoute.name,
path: '/now-playing',
),
];
}
/// generated route for
/// [EmptyRouterPage]
class RootRouter extends PageRouteInfo<void> {
const RootRouter({List<PageRouteInfo>? children})
: super(
RootRouter.name,
path: '/',
initialChildren: children,
);
static const String name = 'RootRouter';
}
/// generated route for
/// [NowPlayingPage]
class NowPlayingRoute extends PageRouteInfo<void> {
const NowPlayingRoute()
: super(
NowPlayingRoute.name,
path: '/now-playing',
);
static const String name = 'NowPlayingRoute';
}
/// generated route for
/// [BottomNavTabsPage]
class BottomNavTabsRoute extends PageRouteInfo<void> {
const BottomNavTabsRoute({List<PageRouteInfo>? children})
: super(
BottomNavTabsRoute.name,
path: '',
initialChildren: children,
);
static const String name = 'BottomNavTabsRoute';
}
/// generated route for
/// [EmptyRouterPage]
class LibraryRouter extends PageRouteInfo<void> {
const LibraryRouter({List<PageRouteInfo>? children})
: super(
LibraryRouter.name,
path: 'library',
initialChildren: children,
);
static const String name = 'LibraryRouter';
}
/// generated route for
/// [EmptyRouterPage]
class BrowseRouter extends PageRouteInfo<void> {
const BrowseRouter({List<PageRouteInfo>? children})
: super(
BrowseRouter.name,
path: 'browse',
initialChildren: children,
);
static const String name = 'BrowseRouter';
}
/// generated route for
/// [EmptyRouterPage]
class SearchRouter extends PageRouteInfo<void> {
const SearchRouter({List<PageRouteInfo>? children})
: super(
SearchRouter.name,
path: 'search',
initialChildren: children,
);
static const String name = 'SearchRouter';
}
/// generated route for
/// [EmptyRouterPage]
class SettingsRouter extends PageRouteInfo<void> {
const SettingsRouter({List<PageRouteInfo>? children})
: super(
SettingsRouter.name,
path: 'settings',
initialChildren: children,
);
static const String name = 'SettingsRouter';
}
/// generated route for
/// [LibraryTabsPage]
class LibraryTabsRoute extends PageRouteInfo<void> {
const LibraryTabsRoute({List<PageRouteInfo>? children})
: super(
LibraryTabsRoute.name,
path: '',
initialChildren: children,
);
static const String name = 'LibraryTabsRoute';
}
/// generated route for
/// [AlbumSongsPage]
class AlbumSongsRoute extends PageRouteInfo<AlbumSongsRouteArgs> {
AlbumSongsRoute({
Key? key,
required String id,
}) : super(
AlbumSongsRoute.name,
path: 'album/:id',
args: AlbumSongsRouteArgs(
key: key,
id: id,
),
rawPathParams: {'id': id},
);
static const String name = 'AlbumSongsRoute';
}
class AlbumSongsRouteArgs {
const AlbumSongsRouteArgs({
this.key,
required this.id,
});
final Key? key;
final String id;
@override
String toString() {
return 'AlbumSongsRouteArgs{key: $key, id: $id}';
}
}
/// generated route for
/// [ArtistPage]
class ArtistRoute extends PageRouteInfo<ArtistRouteArgs> {
ArtistRoute({
Key? key,
required String id,
}) : super(
ArtistRoute.name,
path: 'artist/:id',
args: ArtistRouteArgs(
key: key,
id: id,
),
rawPathParams: {'id': id},
);
static const String name = 'ArtistRoute';
}
class ArtistRouteArgs {
const ArtistRouteArgs({
this.key,
required this.id,
});
final Key? key;
final String id;
@override
String toString() {
return 'ArtistRouteArgs{key: $key, id: $id}';
}
}
/// generated route for
/// [PlaylistSongsPage]
class PlaylistSongsRoute extends PageRouteInfo<PlaylistSongsRouteArgs> {
PlaylistSongsRoute({
Key? key,
required String id,
}) : super(
PlaylistSongsRoute.name,
path: 'playlist/:id',
args: PlaylistSongsRouteArgs(
key: key,
id: id,
),
rawPathParams: {'id': id},
);
static const String name = 'PlaylistSongsRoute';
}
class PlaylistSongsRouteArgs {
const PlaylistSongsRouteArgs({
this.key,
required this.id,
});
final Key? key;
final String id;
@override
String toString() {
return 'PlaylistSongsRouteArgs{key: $key, id: $id}';
}
}
/// generated route for
/// [GenreSongsPage]
class GenreSongsRoute extends PageRouteInfo<GenreSongsRouteArgs> {
GenreSongsRoute({
Key? key,
required String genre,
}) : super(
GenreSongsRoute.name,
path: 'genre/:genre',
args: GenreSongsRouteArgs(
key: key,
genre: genre,
),
rawPathParams: {'genre': genre},
);
static const String name = 'GenreSongsRoute';
}
class GenreSongsRouteArgs {
const GenreSongsRouteArgs({
this.key,
required this.genre,
});
final Key? key;
final String genre;
@override
String toString() {
return 'GenreSongsRouteArgs{key: $key, genre: $genre}';
}
}
/// generated route for
/// [LibraryAlbumsPage]
class LibraryAlbumsRoute extends PageRouteInfo<void> {
const LibraryAlbumsRoute()
: super(
LibraryAlbumsRoute.name,
path: 'albums',
);
static const String name = 'LibraryAlbumsRoute';
}
/// generated route for
/// [LibraryArtistsPage]
class LibraryArtistsRoute extends PageRouteInfo<void> {
const LibraryArtistsRoute()
: super(
LibraryArtistsRoute.name,
path: 'artists',
);
static const String name = 'LibraryArtistsRoute';
}
/// generated route for
/// [LibraryPlaylistsPage]
class LibraryPlaylistsRoute extends PageRouteInfo<void> {
const LibraryPlaylistsRoute()
: super(
LibraryPlaylistsRoute.name,
path: 'playlists',
);
static const String name = 'LibraryPlaylistsRoute';
}
/// generated route for
/// [LibrarySongsPage]
class LibrarySongsRoute extends PageRouteInfo<void> {
const LibrarySongsRoute()
: super(
LibrarySongsRoute.name,
path: 'songs',
);
static const String name = 'LibrarySongsRoute';
}
/// generated route for
/// [BrowsePage]
class BrowseRoute extends PageRouteInfo<void> {
const BrowseRoute()
: super(
BrowseRoute.name,
path: '',
);
static const String name = 'BrowseRoute';
}
/// generated route for
/// [SearchPage]
class SearchRoute extends PageRouteInfo<void> {
const SearchRoute()
: super(
SearchRoute.name,
path: '',
);
static const String name = 'SearchRoute';
}
/// generated route for
/// [SettingsPage]
class SettingsRoute extends PageRouteInfo<void> {
const SettingsRoute()
: super(
SettingsRoute.name,
path: '',
);
static const String name = 'SettingsRoute';
}
/// generated route for
/// [SourcePage]
class SourceRoute extends PageRouteInfo<SourceRouteArgs> {
SourceRoute({
Key? key,
int? id,
}) : super(
SourceRoute.name,
path: 'source/:id',
args: SourceRouteArgs(
key: key,
id: id,
),
rawPathParams: {'id': id},
);
static const String name = 'SourceRoute';
}
class SourceRouteArgs {
const SourceRouteArgs({
this.key,
this.id,
});
final Key? key;
final int? id;
@override
String toString() {
return 'SourceRouteArgs{key: $key, id: $id}';
}
}

View File

@ -1,63 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class ShuffleFab extends StatelessWidget {
final void Function()? onPressed;
const ShuffleFab({
super.key,
this.onPressed,
});
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
return FloatingActionButton(
heroTag: null,
onPressed: onPressed,
tooltip: l.actionsCancel,
child: const Icon(Icons.shuffle_rounded),
);
}
}
class RadioPlayFab extends StatelessWidget {
final void Function()? onPressed;
const RadioPlayFab({
super.key,
this.onPressed,
});
@override
Widget build(BuildContext context) {
return FloatingActionButton(
heroTag: null,
onPressed: onPressed,
child: Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.radio_rounded),
Positioned(
bottom: -11,
right: -10,
child: Icon(
Icons.play_arrow_rounded,
color: Theme.of(context).colorScheme.primaryContainer,
size: 26,
),
),
const Positioned(
bottom: -6,
right: -5,
child: Icon(
Icons.play_arrow_rounded,
size: 16,
),
),
],
),
);
}
}

View File

@ -1,414 +0,0 @@
// ignore_for_file: use_build_context_synchronously
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../models/music.dart';
import '../services/cache_service.dart';
import '../state/theme.dart';
import 'app_router.dart';
import 'hooks/use_download_actions.dart';
import 'images.dart';
enum MenuSize {
small,
medium,
}
Future<T?> showContextMenu<T>({
required BuildContext context,
required WidgetRef ref,
required WidgetBuilder builder,
}) {
return showModalBottomSheet<T>(
backgroundColor: ref.read(baseThemeProvider).theme.colorScheme.background,
useRootNavigator: true,
isScrollControlled: true,
context: context,
builder: builder,
);
}
class BottomSheetMenu extends HookConsumerWidget {
final Widget child;
final MenuSize size;
const BottomSheetMenu({
super.key,
required this.child,
this.size = MenuSize.medium,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = ref.watch(baseThemeProvider);
final height = size == MenuSize.medium ? 0.4 : 0.25;
return Theme(
data: theme.theme,
child: DraggableScrollableSheet(
expand: false,
initialChildSize: height,
maxChildSize: height,
minChildSize: height - 0.05,
snap: true,
snapSizes: [height - 0.05, height],
builder: (context, scrollController) {
return PrimaryScrollController(
controller: scrollController,
child: SizedBox(
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: child,
),
),
);
},
),
);
}
}
class AlbumContextMenu extends HookConsumerWidget {
final Album album;
const AlbumContextMenu({
super.key,
required this.album,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final downloadActions = useAlbumDownloadActions(
context: context,
ref: ref,
album: album,
);
return ListView(
children: [
_AlbumHeader(album: album),
const SizedBox(height: 8),
const _Star(),
if (album.artistId != null) _ViewArtist(id: album.artistId!),
for (var action in downloadActions)
_DownloadAction(key: ValueKey(action.type), downloadAction: action),
],
);
}
}
class SongContextMenu extends HookConsumerWidget {
final Song song;
const SongContextMenu({
super.key,
required this.song,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView(
children: [
_SongHeader(song: song),
const SizedBox(height: 8),
const _Star(),
if (song.artistId != null) _ViewArtist(id: song.artistId!),
if (song.albumId != null) _ViewAlbum(id: song.albumId!),
// const _DownloadAction(),
],
);
}
}
class ArtistContextMenu extends HookConsumerWidget {
final Artist artist;
const ArtistContextMenu({
super.key,
required this.artist,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView(
children: [
_ArtistHeader(artist: artist),
const SizedBox(height: 8),
const _Star(),
// const _Download(),
],
);
}
}
class PlaylistContextMenu extends HookConsumerWidget {
final Playlist playlist;
const PlaylistContextMenu({
super.key,
required this.playlist,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final downloadActions = usePlaylistDownloadActions(
context: context,
ref: ref,
playlist: playlist,
);
return ListView(
children: [
_PlaylistHeader(playlist: playlist),
const SizedBox(height: 8),
for (var action in downloadActions)
_DownloadAction(key: ValueKey(action.type), downloadAction: action),
],
);
}
}
class _AlbumHeader extends HookConsumerWidget {
final Album album;
const _AlbumHeader({
required this.album,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cache = ref.watch(cacheServiceProvider);
return _Header(
title: album.name,
subtitle: album.albumArtist,
image: CardClip(
child: UriCacheInfoImage(
cache: cache.albumArt(album, thumbnail: true),
),
),
);
}
}
class _SongHeader extends HookConsumerWidget {
final Song song;
const _SongHeader({
required this.song,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return _Header(
title: song.title,
subtitle: song.artist,
image: SongAlbumArt(song: song, square: false),
);
}
}
class _ArtistHeader extends HookConsumerWidget {
final Artist artist;
const _ArtistHeader({
required this.artist,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
return _Header(
title: artist.name,
subtitle: l.resourcesAlbumCount(artist.albumCount),
image: CircleClip(child: ArtistArtImage(artistId: artist.id)),
);
}
}
class _PlaylistHeader extends HookConsumerWidget {
final Playlist playlist;
const _PlaylistHeader({
required this.playlist,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cache = ref.watch(cacheServiceProvider);
final l = AppLocalizations.of(context);
return _Header(
title: playlist.name,
subtitle: l.resourcesSongCount(playlist.songCount),
image: CardClip(
child: UriCacheInfoImage(
cache: cache.playlistArt(playlist, thumbnail: true),
),
),
);
}
}
class _Header extends HookConsumerWidget {
final String title;
final String? subtitle;
final Widget image;
const _Header({
required this.title,
this.subtitle,
required this.image,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 80, width: 80, child: image),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleLarge),
if (subtitle != null)
Text(subtitle!, style: theme.textTheme.titleSmall),
],
),
)
],
),
);
}
}
class _Star extends HookConsumerWidget {
const _Star();
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
return _MenuItem(
title: l.actionsStar,
icon: const Icon(Icons.star_outline_rounded),
onTap: () {},
);
}
}
class _DownloadAction extends HookConsumerWidget {
final DownloadAction downloadAction;
const _DownloadAction({
super.key,
required this.downloadAction,
});
String _actionText(AppLocalizations l) {
switch (downloadAction.type) {
case DownloadActionType.download:
return l.actionsDownload;
case DownloadActionType.cancel:
return l.actionsDownloadCancel;
case DownloadActionType.delete:
return l.actionsDownloadDelete;
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return _MenuItem(
title: _actionText(AppLocalizations.of(context)),
icon: downloadAction.iconBuilder(context),
onTap: downloadAction.action,
);
}
}
class _ViewArtist extends HookConsumerWidget {
final String id;
const _ViewArtist({
required this.id,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
return _MenuItem(
title: l.resourcesArtistActionsView,
icon: const Icon(Icons.person_rounded),
onTap: () async {
final router = context.router;
await router.pop();
if (router.currentPath == '/now-playing') {
await router.pop();
await router.navigate(const LibraryRouter());
}
await router.navigate(ArtistRoute(id: id));
},
);
}
}
class _ViewAlbum extends HookConsumerWidget {
final String id;
const _ViewAlbum({
required this.id,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
return _MenuItem(
title: l.resourcesAlbumActionsView,
icon: const Icon(Icons.album_rounded),
onTap: () async {
final router = context.router;
await router.pop();
if (router.currentPath == '/now-playing') {
await router.pop();
await router.navigate(const LibraryRouter());
}
await router.navigate(AlbumSongsRoute(id: id));
},
);
}
}
class _MenuItem extends StatelessWidget {
final String title;
final Widget icon;
final FutureOr<void> Function()? onTap;
const _MenuItem({
required this.title,
required this.icon,
this.onTap,
});
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title),
leading: Padding(
padding: const EdgeInsetsDirectional.only(start: 8),
child: icon,
),
onTap: onTap,
);
}
}

View File

@ -1,96 +0,0 @@
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../models/support.dart';
import '../state/theme.dart';
class DeleteDialog extends HookConsumerWidget {
const DeleteDialog({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = ref.watch(baseThemeProvider);
final l = AppLocalizations.of(context);
return Theme(
data: theme.theme,
child: AlertDialog(
title: Text(l.resourcesSongListDeleteAllTitle),
content: Text(l.resourcesSongListDeleteAllContent),
actions: [
FilledButton.tonal(
onPressed: () => Navigator.pop(context, false),
child: Text(l.actionsCancel),
),
FilledButton.icon(
onPressed: () => Navigator.pop(context, true),
label: Text(l.actionsDelete),
icon: const Icon(Icons.delete_forever_rounded),
),
],
),
);
}
}
class MultipleChoiceDialog<T> extends HookConsumerWidget {
final String title;
final T current;
final IList<MultiChoiceOption> options;
const MultipleChoiceDialog({
super.key,
required this.title,
required this.current,
required this.options,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
final state = useState<T>(current);
List<Widget> choices = [];
for (var opt in options) {
final value = opt.map(
(value) => null,
int: (value) => value.option,
string: (value) => value.option,
) as T;
choices.add(RadioListTile<T>(
value: value,
groupValue: state.value,
title: Text(opt.title),
onChanged: (value) => state.value = value as T,
));
}
return AlertDialog(
title: Text(title),
contentPadding: const EdgeInsets.symmetric(vertical: 20),
content: Material(
type: MaterialType.transparency,
child: SingleChildScrollView(
child: Column(children: choices),
),
),
actions: [
FilledButton.tonal(
onPressed: () => Navigator.pop(context, null),
child: Text(l.actionsCancel),
),
FilledButton.icon(
onPressed: () => Navigator.pop(context, state.value),
label: Text(l.actionsOk),
icon: const Icon(Icons.check_rounded),
),
],
);
}
}

View File

@ -1,76 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../models/support.dart';
import '../state/theme.dart';
class MediaItemGradient extends ConsumerWidget {
const MediaItemGradient({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = ref.watch(mediaItemThemeProvider).valueOrNull;
return BackgroundGradient(colors: colors);
}
}
class AlbumArtGradient extends ConsumerWidget {
final String id;
const AlbumArtGradient({
super.key,
required this.id,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = ref.watch(albumArtThemeProvider(id)).valueOrNull;
return BackgroundGradient(colors: colors);
}
}
class PlaylistArtGradient extends ConsumerWidget {
final String id;
const PlaylistArtGradient({
super.key,
required this.id,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = ref.watch(playlistArtThemeProvider(id)).valueOrNull;
return BackgroundGradient(colors: colors);
}
}
class BackgroundGradient extends HookConsumerWidget {
final ColorTheme? colors;
const BackgroundGradient({
super.key,
this.colors,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final base = ref.watch(baseThemeProvider);
return SizedBox(
width: double.infinity,
height: MediaQuery.of(context).size.height,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colors?.gradientHigh ?? base.gradientHigh,
colors?.gradientLow ?? base.gradientLow,
],
),
),
),
);
}
}

View File

@ -1,149 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../models/music.dart';
import '../../models/support.dart';
import '../../services/download_service.dart';
import '../../state/music.dart';
import '../../state/settings.dart';
import '../dialogs.dart';
enum DownloadActionType {
download,
cancel,
delete,
}
class DownloadAction {
final DownloadActionType type;
final WidgetBuilder iconBuilder;
final FutureOr<void> Function()? action;
const DownloadAction({
required this.type,
required this.iconBuilder,
this.action,
});
}
List<DownloadAction> useAlbumDownloadActions({
required BuildContext context,
required WidgetRef ref,
required Album album,
}) {
final status = ref.watch(albumDownloadStatusProvider(album.id)).valueOrNull;
return useListDownloadActions(
context: context,
ref: ref,
list: album,
status: status,
onDownload: () =>
ref.read(downloadServiceProvider.notifier).downloadAlbum(album),
onDelete: () =>
ref.read(downloadServiceProvider.notifier).deleteAlbum(album),
onCancel: () =>
ref.read(downloadServiceProvider.notifier).cancelAlbum(album),
);
}
List<DownloadAction> usePlaylistDownloadActions({
required BuildContext context,
required WidgetRef ref,
required Playlist playlist,
}) {
final status =
ref.watch(playlistDownloadStatusProvider(playlist.id)).valueOrNull;
return useListDownloadActions(
context: context,
ref: ref,
list: playlist,
status: status,
onDownload: () =>
ref.read(downloadServiceProvider.notifier).downloadPlaylist(playlist),
onDelete: () =>
ref.read(downloadServiceProvider.notifier).deletePlaylist(playlist),
onCancel: () =>
ref.read(downloadServiceProvider.notifier).cancelPlaylist(playlist),
);
}
List<DownloadAction> useListDownloadActions({
required BuildContext context,
required WidgetRef ref,
required SourceIdentifiable list,
required ListDownloadStatus? status,
required FutureOr<void> Function() onDelete,
required FutureOr<void> Function() onCancel,
required FutureOr<void> Function() onDownload,
}) {
status ??= const ListDownloadStatus(total: 0, downloaded: 0, downloading: 0);
final sourceId = SourceId.from(list);
final offline = ref.watch(offlineModeProvider);
final listDownloadInProgress = ref.watch(downloadServiceProvider
.select((value) => value.listDownloads.contains(sourceId)));
final listDeleteInProgress = ref.watch(downloadServiceProvider
.select((value) => value.deletes.contains(sourceId)));
final listCancelInProgress = ref.watch(downloadServiceProvider
.select((value) => value.listCancels.contains(sourceId)));
DownloadAction delete() {
return DownloadAction(
type: DownloadActionType.delete,
iconBuilder: (context) => const Icon(Icons.delete_forever_rounded),
action: listDeleteInProgress
? null
: () async {
final ok = await showDialog<bool>(
context: context,
builder: (context) => const DeleteDialog(),
);
if (ok == true) {
await onDelete();
}
},
);
}
DownloadAction cancel() {
return DownloadAction(
type: DownloadActionType.cancel,
iconBuilder: (context) => Stack(
alignment: Alignment.center,
children: const [
Icon(Icons.cancel_rounded),
SizedBox(
height: 32,
width: 32,
child: CircularProgressIndicator(
strokeWidth: 3,
),
),
],
),
action: listCancelInProgress ? null : onCancel,
);
}
DownloadAction download() {
return DownloadAction(
type: DownloadActionType.download,
iconBuilder: (context) => const Icon(Icons.download_rounded),
action: !offline ? onDownload : null,
);
}
if (status.total == status.downloaded) {
return [delete()];
} else if (status.downloading == 0 && status.downloaded > 0) {
return [download(), delete()];
} else if (listDownloadInProgress || status.downloading > 0) {
return [cancel()];
} else {
return [download()];
}
}

View File

@ -1,91 +0,0 @@
import 'dart:async';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import '../../models/query.dart';
import '../../services/sync_service.dart';
import '../../state/settings.dart';
import '../pages/library_page.dart';
import 'use_paging_controller.dart';
PagingController<int, T> useLibraryPagingController<T>(
WidgetRef ref, {
required int libraryTabIndex,
required FutureOr<List<T>> Function(ListQuery query) getItems,
}) {
final queryProvider = libraryListQueryProvider(libraryTabIndex).select(
(value) => value.query,
);
final query = useState(ref.read(queryProvider));
final onPageRequest = useCallback(
(int pageKey, PagingController<int, T> pagingController) =>
_pageRequest(getItems, query.value, pageKey, pagingController),
[query.value],
);
final pagingController = usePagingController<int, T>(
firstPageKey: query.value.page.offset,
onPageRequest: onPageRequest,
);
ref.listen(queryProvider, (_, next) {
query.value = next;
pagingController.refresh();
});
ref.listen(syncServiceProvider, (_, __) => pagingController.refresh());
ref.listen(sourceIdProvider, (_, __) => pagingController.refresh());
ref.listen(offlineModeProvider, (_, __) => pagingController.refresh());
return pagingController;
}
PagingController<int, T> useListQueryPagingController<T>(
WidgetRef ref, {
required ListQuery query,
required FutureOr<List<T>> Function(ListQuery query) getItems,
}) {
final onPageRequest = useCallback(
(int pageKey, PagingController<int, T> pagingController) =>
_pageRequest(getItems, query, pageKey, pagingController),
[query],
);
final pagingController = usePagingController<int, T>(
firstPageKey: query.page.offset,
onPageRequest: onPageRequest,
);
return pagingController;
}
Future<void> _pageRequest<T>(
FutureOr<List<T>> Function(ListQuery query) getItems,
ListQuery query,
int pageKey,
PagingController<int, dynamic> pagingController,
) async {
try {
final newItems = await getItems(query.copyWith.page(offset: pageKey));
final isFirstPage = newItems.isNotEmpty && pageKey == 0;
final alreadyHasItems = pagingController.itemList != null &&
pagingController.itemList!.isNotEmpty;
if (isFirstPage && alreadyHasItems) {
return;
}
final isLastPage = newItems.length < query.page.limit;
if (isLastPage) {
pagingController.appendLastPage(newItems);
} else {
final nextPageKey = pageKey + newItems.length;
pagingController.appendPage(newItems, nextPageKey);
}
} catch (error) {
pagingController.error = error;
}
}

View File

@ -1,66 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
PagingController<PageKeyType, ItemType>
usePagingController<PageKeyType, ItemType>({
required final PageKeyType firstPageKey,
final int? invisibleItemsThreshold,
List<Object?>? keys,
FutureOr<void> Function(PageKeyType pageKey,
PagingController<PageKeyType, ItemType> pagingController)?
onPageRequest,
}) {
final controller = use(
_PagingControllerHook<PageKeyType, ItemType>(
firstPageKey: firstPageKey,
invisibleItemsThreshold: invisibleItemsThreshold,
keys: keys,
),
);
useEffect(() {
listener(PageKeyType pageKey) => onPageRequest?.call(pageKey, controller);
controller.addPageRequestListener(listener);
return () => controller.removePageRequestListener(listener);
}, [onPageRequest]);
return controller;
}
class _PagingControllerHook<PageKeyType, ItemType>
extends Hook<PagingController<PageKeyType, ItemType>> {
const _PagingControllerHook({
required this.firstPageKey,
this.invisibleItemsThreshold,
List<Object?>? keys,
}) : super(keys: keys);
final PageKeyType firstPageKey;
final int? invisibleItemsThreshold;
@override
HookState<PagingController<PageKeyType, ItemType>,
Hook<PagingController<PageKeyType, ItemType>>>
createState() => _PagingControllerHookState<PageKeyType, ItemType>();
}
class _PagingControllerHookState<PageKeyType, ItemType> extends HookState<
PagingController<PageKeyType, ItemType>,
_PagingControllerHook<PageKeyType, ItemType>> {
late final controller = PagingController<PageKeyType, ItemType>(
firstPageKey: hook.firstPageKey,
invisibleItemsThreshold: hook.invisibleItemsThreshold);
@override
PagingController<PageKeyType, ItemType> build(BuildContext context) =>
controller;
@override
void dispose() => controller.dispose();
@override
String get debugLabel => 'usePagingController';
}

View File

@ -1,368 +0,0 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../models/music.dart';
import '../models/support.dart';
import '../services/cache_service.dart';
import '../state/music.dart';
import '../state/settings.dart';
import '../state/theme.dart';
part 'images.g.dart';
@riverpod
CacheInfo _artistArtCacheInfo(
_ArtistArtCacheInfoRef ref, {
required String artistId,
bool thumbnail = true,
}) {
final cache = ref.watch(cacheServiceProvider);
return cache.artistArtCacheInfo(artistId, thumbnail: thumbnail);
}
@riverpod
FutureOr<String?> _artistArtCachedUrl(
_ArtistArtCachedUrlRef ref, {
required String artistId,
bool thumbnail = true,
}) async {
final cache = ref.watch(_artistArtCacheInfoProvider(
artistId: artistId,
thumbnail: thumbnail,
));
final file = await cache.cacheManager.getFileFromCache(cache.cacheKey);
return file?.originalUrl;
}
@riverpod
FutureOr<UriCacheInfo> _artistArtUriCacheInfo(
_ArtistArtUriCacheInfoRef ref, {
required String artistId,
bool thumbnail = true,
}) async {
final cache = ref.watch(cacheServiceProvider);
final info = ref.watch(_artistArtCacheInfoProvider(
artistId: artistId,
thumbnail: thumbnail,
));
final cachedUrl = await ref.watch(_artistArtCachedUrlProvider(
artistId: artistId,
thumbnail: thumbnail,
).future);
final offline = ref.watch(offlineModeProvider);
// already cached, don't try to get the real url again
if (cachedUrl != null) {
return UriCacheInfo(
uri: Uri.parse(cachedUrl),
cacheKey: info.cacheKey,
cacheManager: info.cacheManager,
);
}
if (offline) {
final file = await cache.imageCache.getFileFromCache(info.cacheKey);
if (file != null) {
return UriCacheInfo(
uri: Uri.parse(file.originalUrl),
cacheKey: info.cacheKey,
cacheManager: info.cacheManager,
);
} else {
return cache.placeholder(thumbnail: thumbnail);
}
}
// assume the url is good or let this fail
return UriCacheInfo(
uri: (await cache.artistArtUri(artistId, thumbnail: thumbnail))!,
cacheKey: info.cacheKey,
cacheManager: info.cacheManager,
);
}
class ArtistArtImage extends HookConsumerWidget {
final String artistId;
final bool thumbnail;
final BoxFit fit;
final PlaceholderStyle placeholderStyle;
final double? height;
final double? width;
const ArtistArtImage({
super.key,
required this.artistId,
this.thumbnail = true,
this.fit = BoxFit.cover,
this.placeholderStyle = PlaceholderStyle.color,
this.height,
this.width,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cache = ref.watch(_artistArtUriCacheInfoProvider(
artistId: artistId,
thumbnail: thumbnail,
));
// TODO: figure out how to animate this without messing up with boxfit/ratio
return cache.when(
data: (data) => UriCacheInfoImage(
cache: data,
fit: fit,
placeholderStyle: placeholderStyle,
height: height,
width: width,
),
error: (_, __) => Container(
color: Colors.red,
height: height,
width: width,
),
loading: () => Container(
color: Theme.of(context).colorScheme.secondaryContainer,
height: height,
width: width,
),
);
}
}
class SongAlbumArt extends HookConsumerWidget {
final Song song;
final bool square;
const SongAlbumArt({
super.key,
required this.song,
this.square = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(albumProvider(song.albumId!)).valueOrNull;
return AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
child: album != null ? AlbumArt(album: album) : const PlaceholderImage(),
);
}
}
class AlbumArt extends HookConsumerWidget {
final Album album;
final bool square;
const AlbumArt({
super.key,
required this.album,
this.square = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// generate the palette used in other views ahead of time
ref.watch(albumArtPaletteProvider(album.id));
final cache = ref.watch(cacheServiceProvider);
Widget image = UriCacheInfoImage(cache: cache.albumArt(album));
if (square) {
image = AspectRatio(aspectRatio: 1.0, child: image);
}
return CardClip(child: image);
}
}
class CircleClip extends StatelessWidget {
final Widget child;
const CircleClip({
super.key,
required this.child,
});
@override
Widget build(BuildContext context) {
return ClipOval(
clipBehavior: Clip.antiAlias,
child: AspectRatio(
aspectRatio: 1.0,
child: child,
),
);
}
}
class CardClip extends StatelessWidget {
final Widget child;
final bool square;
const CardClip({
super.key,
required this.child,
this.square = true,
});
@override
Widget build(BuildContext context) {
final cardShape = Theme.of(context).cardTheme.shape;
return ClipRRect(
borderRadius:
cardShape is RoundedRectangleBorder ? cardShape.borderRadius : null,
child: !square
? child
: AspectRatio(
aspectRatio: 1.0,
child: child,
),
);
}
}
enum PlaceholderStyle {
color,
spinner,
}
class UriCacheInfoImage extends StatelessWidget {
final UriCacheInfo cache;
final BoxFit fit;
final PlaceholderStyle placeholderStyle;
final double? height;
final double? width;
const UriCacheInfoImage({
super.key,
required this.cache,
this.fit = BoxFit.cover,
this.placeholderStyle = PlaceholderStyle.color,
this.height,
this.width,
});
@override
Widget build(BuildContext context) {
return CachedNetworkImage(
imageUrl: cache.uri.toString(),
cacheKey: cache.cacheKey,
cacheManager: cache.cacheManager,
fit: fit,
height: height,
width: width,
fadeInDuration: const Duration(milliseconds: 300),
fadeOutDuration: const Duration(milliseconds: 500),
placeholder: (context, url) =>
placeholderStyle == PlaceholderStyle.spinner
? Container()
: Container(
color: Theme.of(context).colorScheme.secondaryContainer,
),
errorWidget: (context, url, error) => PlaceholderImage(
fit: fit,
height: height,
width: width,
),
);
}
}
class PlaceholderImage extends HookConsumerWidget {
final BoxFit fit;
final double? height;
final double? width;
final bool thumbnail;
const PlaceholderImage({
super.key,
this.fit = BoxFit.cover,
this.height,
this.width,
this.thumbnail = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Image.asset(
thumbnail ? 'assets/placeholder_thumb.png' : 'assets/placeholder.png',
fit: fit,
height: height,
width: width,
);
}
}
class _ExpandedRatio extends StatelessWidget {
final Widget child;
final double aspectRatio;
const _ExpandedRatio({
required this.child,
this.aspectRatio = 1.0,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: AspectRatio(
aspectRatio: aspectRatio,
child: child,
),
);
}
}
class MultiImage extends HookConsumerWidget {
final Iterable<UriCacheInfo> cacheInfo;
const MultiImage({
super.key,
required this.cacheInfo,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final images = cacheInfo.map((cache) => UriCacheInfoImage(cache: cache));
final row1 = <Widget>[];
final row2 = <Widget>[];
if (images.length >= 4) {
row1.addAll([
_ExpandedRatio(child: images.elementAt(0)),
_ExpandedRatio(child: images.elementAt(1)),
]);
row2.addAll([
_ExpandedRatio(child: images.elementAt(2)),
_ExpandedRatio(child: images.elementAt(3)),
]);
}
if (images.length == 3) {
row1.addAll([
_ExpandedRatio(child: images.elementAt(0)),
_ExpandedRatio(child: images.elementAt(1)),
]);
row2.addAll([
_ExpandedRatio(aspectRatio: 2.0, child: images.elementAt(2)),
]);
}
if (images.length == 2) {
row1.add(_ExpandedRatio(aspectRatio: 2.0, child: images.elementAt(0)));
row2.add(_ExpandedRatio(aspectRatio: 2.0, child: images.elementAt(1)));
}
if (images.length == 1) {
row1.addAll([_ExpandedRatio(child: images.elementAt(0))]);
}
return Column(
children: [
Row(children: row1),
Row(children: row2),
],
);
}
}

View File

@ -1,307 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'images.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$artistArtCacheInfoHash() =>
r'f82d3e91aa1596939e376c6a7ea7d3e974c6f0fc';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
typedef _ArtistArtCacheInfoRef = AutoDisposeProviderRef<CacheInfo>;
/// See also [_artistArtCacheInfo].
@ProviderFor(_artistArtCacheInfo)
const _artistArtCacheInfoProvider = _ArtistArtCacheInfoFamily();
/// See also [_artistArtCacheInfo].
class _ArtistArtCacheInfoFamily extends Family<CacheInfo> {
/// See also [_artistArtCacheInfo].
const _ArtistArtCacheInfoFamily();
/// See also [_artistArtCacheInfo].
_ArtistArtCacheInfoProvider call({
required String artistId,
bool thumbnail = true,
}) {
return _ArtistArtCacheInfoProvider(
artistId: artistId,
thumbnail: thumbnail,
);
}
@override
_ArtistArtCacheInfoProvider getProviderOverride(
covariant _ArtistArtCacheInfoProvider provider,
) {
return call(
artistId: provider.artistId,
thumbnail: provider.thumbnail,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'_artistArtCacheInfoProvider';
}
/// See also [_artistArtCacheInfo].
class _ArtistArtCacheInfoProvider extends AutoDisposeProvider<CacheInfo> {
/// See also [_artistArtCacheInfo].
_ArtistArtCacheInfoProvider({
required this.artistId,
this.thumbnail = true,
}) : super.internal(
(ref) => _artistArtCacheInfo(
ref,
artistId: artistId,
thumbnail: thumbnail,
),
from: _artistArtCacheInfoProvider,
name: r'_artistArtCacheInfoProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$artistArtCacheInfoHash,
dependencies: _ArtistArtCacheInfoFamily._dependencies,
allTransitiveDependencies:
_ArtistArtCacheInfoFamily._allTransitiveDependencies,
);
final String artistId;
final bool thumbnail;
@override
bool operator ==(Object other) {
return other is _ArtistArtCacheInfoProvider &&
other.artistId == artistId &&
other.thumbnail == thumbnail;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, artistId.hashCode);
hash = _SystemHash.combine(hash, thumbnail.hashCode);
return _SystemHash.finish(hash);
}
}
String _$artistArtCachedUrlHash() =>
r'2a5e0fea614ff12a1d562faccec6cfe98394af42';
typedef _ArtistArtCachedUrlRef = AutoDisposeFutureProviderRef<String?>;
/// See also [_artistArtCachedUrl].
@ProviderFor(_artistArtCachedUrl)
const _artistArtCachedUrlProvider = _ArtistArtCachedUrlFamily();
/// See also [_artistArtCachedUrl].
class _ArtistArtCachedUrlFamily extends Family<AsyncValue<String?>> {
/// See also [_artistArtCachedUrl].
const _ArtistArtCachedUrlFamily();
/// See also [_artistArtCachedUrl].
_ArtistArtCachedUrlProvider call({
required String artistId,
bool thumbnail = true,
}) {
return _ArtistArtCachedUrlProvider(
artistId: artistId,
thumbnail: thumbnail,
);
}
@override
_ArtistArtCachedUrlProvider getProviderOverride(
covariant _ArtistArtCachedUrlProvider provider,
) {
return call(
artistId: provider.artistId,
thumbnail: provider.thumbnail,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'_artistArtCachedUrlProvider';
}
/// See also [_artistArtCachedUrl].
class _ArtistArtCachedUrlProvider extends AutoDisposeFutureProvider<String?> {
/// See also [_artistArtCachedUrl].
_ArtistArtCachedUrlProvider({
required this.artistId,
this.thumbnail = true,
}) : super.internal(
(ref) => _artistArtCachedUrl(
ref,
artistId: artistId,
thumbnail: thumbnail,
),
from: _artistArtCachedUrlProvider,
name: r'_artistArtCachedUrlProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$artistArtCachedUrlHash,
dependencies: _ArtistArtCachedUrlFamily._dependencies,
allTransitiveDependencies:
_ArtistArtCachedUrlFamily._allTransitiveDependencies,
);
final String artistId;
final bool thumbnail;
@override
bool operator ==(Object other) {
return other is _ArtistArtCachedUrlProvider &&
other.artistId == artistId &&
other.thumbnail == thumbnail;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, artistId.hashCode);
hash = _SystemHash.combine(hash, thumbnail.hashCode);
return _SystemHash.finish(hash);
}
}
String _$artistArtUriCacheInfoHash() =>
r'9bdc0f5654882265236ef746ea697a6d107a4b6f';
typedef _ArtistArtUriCacheInfoRef = AutoDisposeFutureProviderRef<UriCacheInfo>;
/// See also [_artistArtUriCacheInfo].
@ProviderFor(_artistArtUriCacheInfo)
const _artistArtUriCacheInfoProvider = _ArtistArtUriCacheInfoFamily();
/// See also [_artistArtUriCacheInfo].
class _ArtistArtUriCacheInfoFamily extends Family<AsyncValue<UriCacheInfo>> {
/// See also [_artistArtUriCacheInfo].
const _ArtistArtUriCacheInfoFamily();
/// See also [_artistArtUriCacheInfo].
_ArtistArtUriCacheInfoProvider call({
required String artistId,
bool thumbnail = true,
}) {
return _ArtistArtUriCacheInfoProvider(
artistId: artistId,
thumbnail: thumbnail,
);
}
@override
_ArtistArtUriCacheInfoProvider getProviderOverride(
covariant _ArtistArtUriCacheInfoProvider provider,
) {
return call(
artistId: provider.artistId,
thumbnail: provider.thumbnail,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'_artistArtUriCacheInfoProvider';
}
/// See also [_artistArtUriCacheInfo].
class _ArtistArtUriCacheInfoProvider
extends AutoDisposeFutureProvider<UriCacheInfo> {
/// See also [_artistArtUriCacheInfo].
_ArtistArtUriCacheInfoProvider({
required this.artistId,
this.thumbnail = true,
}) : super.internal(
(ref) => _artistArtUriCacheInfo(
ref,
artistId: artistId,
thumbnail: thumbnail,
),
from: _artistArtUriCacheInfoProvider,
name: r'_artistArtUriCacheInfoProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$artistArtUriCacheInfoHash,
dependencies: _ArtistArtUriCacheInfoFamily._dependencies,
allTransitiveDependencies:
_ArtistArtUriCacheInfoFamily._allTransitiveDependencies,
);
final String artistId;
final bool thumbnail;
@override
bool operator ==(Object other) {
return other is _ArtistArtUriCacheInfoProvider &&
other.artistId == artistId &&
other.thumbnail == thumbnail;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, artistId.hashCode);
hash = _SystemHash.combine(hash, thumbnail.hashCode);
return _SystemHash.finish(hash);
}
}
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions

View File

@ -1,433 +0,0 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../models/music.dart';
import '../services/cache_service.dart';
import '../services/download_service.dart';
import '../state/audio.dart';
import '../state/music.dart';
import '../state/theme.dart';
import 'context_menus.dart';
import 'images.dart';
import 'pages/songs_page.dart';
enum CardStyle {
imageOnly,
withText,
}
enum AlbumSubtitle {
artist,
year,
}
class AlbumCard extends HookConsumerWidget {
final Album album;
final void Function()? onTap;
final CardStyle style;
final AlbumSubtitle subtitle;
const AlbumCard({
super.key,
required this.album,
this.onTap,
this.style = CardStyle.withText,
this.subtitle = AlbumSubtitle.artist,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// generate the palette used in other views ahead of time
ref.watch(albumArtPaletteProvider(album.id));
final cache = ref.watch(cacheServiceProvider);
final info = cache.albumArt(album);
final image = CardClip(child: UriCacheInfoImage(cache: info));
Widget content;
if (style == CardStyle.imageOnly) {
content = image;
} else {
content = Column(
children: [
image,
_AlbumCardText(album: album, subtitle: subtitle),
],
);
}
return ImageCard(
onTap: onTap,
onLongPress: () {
showContextMenu(
context: context,
ref: ref,
builder: (context) => BottomSheetMenu(
child: AlbumContextMenu(album: album),
),
);
},
child: content,
);
}
}
class ImageCard extends StatelessWidget {
final Widget child;
final void Function()? onTap;
final void Function()? onLongPress;
const ImageCard({
super.key,
required this.child,
this.onTap,
this.onLongPress,
});
@override
Widget build(BuildContext context) {
return Card(
surfaceTintColor: Colors.transparent,
margin: const EdgeInsets.all(0),
child: Stack(
fit: StackFit.passthrough,
alignment: Alignment.bottomCenter,
children: [
child,
Positioned.fill(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
),
),
),
],
),
);
}
}
class _AlbumCardText extends StatelessWidget {
final Album album;
final AlbumSubtitle subtitle;
const _AlbumCardText({
required this.album,
required this.subtitle,
});
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Container(
padding: const EdgeInsets.only(top: 4, bottom: 8),
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: double.infinity,
child: Text(
album.name,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.fade,
textAlign: TextAlign.start,
style: textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Text(
(subtitle == AlbumSubtitle.artist
? album.albumArtist
: album.year?.toString()) ??
'',
maxLines: 1,
softWrap: false,
overflow: TextOverflow.fade,
textAlign: TextAlign.start,
style: textTheme.bodySmall,
),
],
),
);
}
}
class AlbumListTile extends HookConsumerWidget {
final Album album;
final void Function()? onTap;
const AlbumListTile({
super.key,
required this.album,
this.onTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final artist = ref.watch(albumProvider(album.artistId!)).valueOrNull;
return ListTile(
leading: AlbumArt(album: album),
title: Text(album.name),
subtitle: Text(album.albumArtist ?? artist!.name),
onTap: onTap,
onLongPress: () {
showContextMenu(
context: context,
ref: ref,
builder: (context) => BottomSheetMenu(
size: MenuSize.small,
child: AlbumContextMenu(album: album),
),
);
},
);
}
}
class ArtistListTile extends HookConsumerWidget {
final Artist artist;
final void Function()? onTap;
const ArtistListTile({
super.key,
required this.artist,
this.onTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListTile(
leading: CircleClip(
child: ArtistArtImage(artistId: artist.id),
),
title: Text(artist.name),
subtitle: Text(AppLocalizations.of(context).resourcesAlbumCount(
artist.albumCount,
)),
onTap: onTap,
onLongPress: () {
showContextMenu(
context: context,
ref: ref,
builder: (context) => BottomSheetMenu(
size: MenuSize.small,
child: ArtistContextMenu(artist: artist),
),
);
},
);
}
}
class PlaylistListTile extends HookConsumerWidget {
final Playlist playlist;
final void Function()? onTap;
const PlaylistListTile({
super.key,
required this.playlist,
this.onTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// generate the palette used in other views ahead of time
ref.watch(playlistArtPaletteProvider(playlist.id));
final cache = ref.watch(cacheServiceProvider).playlistArt(playlist);
return ListTile(
leading: CardClip(
child: UriCacheInfoImage(cache: cache),
),
title: Text(playlist.name),
subtitle: Text(AppLocalizations.of(context).resourcesSongCount(
playlist.songCount,
)),
onTap: onTap,
onLongPress: () {
showContextMenu(
context: context,
ref: ref,
builder: (context) => BottomSheetMenu(
size: MenuSize.small,
child: PlaylistContextMenu(playlist: playlist),
),
);
},
);
}
}
class SongListTile extends HookConsumerWidget {
final Song song;
final void Function()? onTap;
final bool image;
const SongListTile({
super.key,
required this.song,
this.onTap,
this.image = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Material(
type: MaterialType.transparency,
child: ListTile(
title: _SongTitle(song: song),
subtitle: _SongSubtitle(song: song),
leading: image ? SongAlbumArt(song: song) : null,
trailing: IconButton(
icon: const Icon(
Icons.star_outline_rounded,
size: 36,
),
onPressed: () {},
),
onTap: onTap,
onLongPress: () {
showContextMenu(
context: context,
ref: ref,
builder: (context) => BottomSheetMenu(
child: SongContextMenu(song: song),
),
);
},
),
);
}
}
class _SongSubtitle extends HookConsumerWidget {
final Song song;
const _SongSubtitle({
required this.song,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final downloadTaskId = ref.watch(songProvider(song.id).select(
(value) => value.valueOrNull?.downloadTaskId,
));
final downloadFilePath = ref.watch(songProvider(song.id).select(
(value) => value.valueOrNull?.downloadFilePath,
));
final download = ref.watch(downloadServiceProvider.select(
(value) => value.downloads.firstWhereOrNull(
(e) => e.taskId == downloadTaskId,
),
));
final inheritedStyle = DefaultTextStyle.of(context).style;
Widget? downloadIndicator;
if (downloadFilePath != null) {
downloadIndicator = const Padding(
padding: EdgeInsetsDirectional.only(end: 3),
child: Icon(
Icons.download_done_rounded,
size: 20,
),
);
} else if (downloadTaskId != null || download != null) {
downloadIndicator = Padding(
padding: const EdgeInsetsDirectional.only(start: 4, end: 9),
child: SizedBox(
height: 10,
width: 10,
child: CircularProgressIndicator(
strokeWidth: 2,
value: download != null && download.progress > 0
? download.progress / 100
: null,
),
),
);
}
return Row(
children: [
if (downloadIndicator != null) downloadIndicator,
Expanded(
child: Text(
song.artist ?? song.album ?? '',
maxLines: 1,
softWrap: false,
overflow: TextOverflow.fade,
style: TextStyle(
color: inheritedStyle.color,
),
),
),
],
);
}
}
class _SongTitle extends HookConsumerWidget {
final Song song;
const _SongTitle({
required this.song,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final mediaItem = ref.watch(mediaItemProvider).valueOrNull;
final mediaItemData = ref.watch(mediaItemDataProvider);
final inheritedStyle = DefaultTextStyle.of(context).style;
final theme = Theme.of(context);
final queueContext = QueueContext.maybeOf(context);
final playing = mediaItem != null &&
mediaItemData != null &&
mediaItem.id == song.id &&
mediaItemData.contextId == queueContext?.id &&
mediaItemData.contextType == queueContext?.type;
return Row(
children: [
if (playing)
Padding(
padding: const EdgeInsetsDirectional.only(end: 2),
child: Icon(
Icons.play_arrow_rounded,
size: 18,
color: theme.colorScheme.primary,
),
),
Expanded(
child: Text(
song.title,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.fade,
style: TextStyle(
color: playing ? theme.colorScheme.primary : inheritedStyle.color,
),
),
),
],
);
}
}
class FabPadding extends StatelessWidget {
const FabPadding({super.key});
@override
Widget build(BuildContext context) {
return const SizedBox(height: 86);
}
}

View File

@ -1,136 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import '../services/sync_service.dart';
import 'items.dart';
import 'snackbars.dart';
class PagedListQueryView<T> extends HookConsumerWidget {
final PagingController<int, T> pagingController;
final bool refreshSyncAll;
final bool fabPadding;
final bool useSliver;
final Widget Function(BuildContext context, T item, int index) itemBuilder;
const PagedListQueryView({
super.key,
required this.pagingController,
this.refreshSyncAll = false,
this.fabPadding = true,
this.useSliver = false,
required this.itemBuilder,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final builderDelegate = PagedChildBuilderDelegate<T>(
itemBuilder: (context, item, index) => itemBuilder(context, item, index),
noMoreItemsIndicatorBuilder:
fabPadding ? (context) => const FabPadding() : null,
);
final listView = useSliver
? PagedSliverList<int, T>(
pagingController: pagingController,
builderDelegate: builderDelegate,
)
: PagedListView<int, T>(
pagingController: pagingController,
builderDelegate: builderDelegate,
);
if (refreshSyncAll) {
return SyncAllRefresh(child: listView);
} else {
return listView;
}
}
}
enum GridSize {
small,
large,
}
class PagedGridQueryView<T> extends HookConsumerWidget {
final PagingController<int, T> pagingController;
final bool refreshSyncAll;
final bool fabPadding;
final GridSize size;
final Widget Function(BuildContext context, T item, int index, GridSize size)
itemBuilder;
const PagedGridQueryView({
super.key,
required this.pagingController,
this.refreshSyncAll = false,
this.fabPadding = true,
this.size = GridSize.small,
required this.itemBuilder,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
SliverGridDelegate gridDelegate;
double spacing;
if (size == GridSize.small) {
spacing = 4;
gridDelegate = SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: spacing,
crossAxisSpacing: spacing,
);
} else {
spacing = 12;
gridDelegate = SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: spacing,
crossAxisSpacing: spacing,
);
}
final listView = PagedGridView<int, T>(
padding: MediaQuery.of(context).padding + EdgeInsets.all(spacing),
pagingController: pagingController,
builderDelegate: PagedChildBuilderDelegate(
itemBuilder: (context, item, index) =>
itemBuilder(context, item, index, size),
noMoreItemsIndicatorBuilder:
fabPadding ? (context) => const FabPadding() : null,
),
gridDelegate: gridDelegate,
showNoMoreItemsIndicatorAsGridChild: false,
);
if (refreshSyncAll) {
return SyncAllRefresh(child: listView);
} else {
return listView;
}
}
}
class SyncAllRefresh extends HookConsumerWidget {
final Widget child;
const SyncAllRefresh({
super.key,
required this.child,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return RefreshIndicator(
onRefresh: () async {
try {
await ref.read(syncServiceProvider.notifier).syncAll();
} catch (e) {
showErrorSnackbar(context, e.toString());
}
},
child: child,
);
}
}

View File

@ -1,226 +0,0 @@
import 'package:audio_service/audio_service.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../cache/image_cache.dart';
import '../models/support.dart';
import '../services/audio_service.dart';
import '../state/audio.dart';
import '../state/theme.dart';
import 'app_router.dart';
import 'images.dart';
import 'pages/now_playing_page.dart';
class NowPlayingBar extends HookConsumerWidget {
const NowPlayingBar({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = ref.watch(mediaItemThemeProvider).valueOrNull;
final noItem = ref.watch(mediaItemProvider).valueOrNull == null;
final widget = GestureDetector(
onTap: () {
context.navigateTo(const NowPlayingRoute());
},
child: Material(
elevation: 3,
color: colors?.darkBackground,
// surfaceTintColor: theme?.colorScheme.background,
child: Column(
children: [
SizedBox(
height: 70,
child: Row(
mainAxisSize: MainAxisSize.max,
children: const [
Padding(
padding: EdgeInsets.all(10),
child: _ArtImage(),
),
Expanded(
child: Padding(
padding: EdgeInsets.only(right: 4),
child: _TrackInfo(),
),
),
Padding(
padding: EdgeInsets.only(right: 16, top: 2),
child: PlayPauseButton(size: 48),
),
],
),
),
const _ProgressBar(),
],
),
),
);
if (noItem) {
return Container();
}
if (colors != null) {
return Theme(data: colors.theme, child: widget);
} else {
return widget;
}
}
}
class _ArtImage extends HookConsumerWidget {
const _ArtImage();
@override
Widget build(BuildContext context, WidgetRef ref) {
final imageCache = ref.watch(imageCacheProvider);
final uri =
ref.watch(mediaItemProvider.select((e) => e.valueOrNull?.artUri));
final cacheKey = ref.watch(mediaItemDataProvider.select(
(value) => value?.artCache?.thumbnailArtCacheKey,
));
UriCacheInfo? cache;
if (uri != null && cacheKey != null) {
cache = UriCacheInfo(
uri: uri,
cacheKey: cacheKey,
cacheManager: imageCache,
);
}
return AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
child: CardClip(
key: ValueKey(cacheKey ?? 'default'),
child: cache == null
? const PlaceholderImage()
: UriCacheInfoImage(
cache: cache,
),
),
);
}
}
class _TrackInfo extends HookConsumerWidget {
const _TrackInfo();
@override
Widget build(BuildContext context, WidgetRef ref) {
final item = ref.watch(mediaItemProvider);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...item.when(
data: (data) => [
// Text(
// data?.title ?? 'Nothing!!!',
// maxLines: 1,
// softWrap: false,
// overflow: TextOverflow.fade,
// style: Theme.of(context).textTheme.labelLarge,
// ),
ScrollableText(
data?.title ?? 'Nothing!!!',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 2),
Text(
data?.artist ?? 'Nothing!!!',
maxLines: 1,
softWrap: false,
overflow: TextOverflow.fade,
style: Theme.of(context).textTheme.labelMedium,
),
],
error: (_, __) => const [Text('Error!')],
loading: () => const [Text('loading.....')],
),
],
);
}
}
class PlayPauseButton extends HookConsumerWidget {
final double size;
const PlayPauseButton({
super.key,
required this.size,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final playing = ref.watch(playingProvider);
final state = ref.watch(processingStateProvider);
Widget icon;
if (state == AudioProcessingState.loading ||
state == AudioProcessingState.buffering) {
icon = Stack(
alignment: Alignment.center,
children: [
const Icon(Icons.circle),
SizedBox(
height: size / 3,
width: size / 3,
child: CircularProgressIndicator(
strokeWidth: size / 16,
color: Theme.of(context).colorScheme.background,
),
),
],
);
} else if (playing) {
icon = const Icon(Icons.pause_circle_rounded);
} else {
icon = const Icon(Icons.play_circle_rounded);
}
return IconButton(
iconSize: size,
padding: EdgeInsets.zero,
onPressed: () {
if (playing) {
ref.read(audioControlProvider).pause();
} else {
ref.read(audioControlProvider).play();
}
},
icon: icon,
color: Theme.of(context).colorScheme.onBackground,
);
}
}
class _ProgressBar extends HookConsumerWidget {
const _ProgressBar();
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = ref.watch(mediaItemThemeProvider).valueOrNull;
final position = ref.watch(positionProvider);
final duration = ref.watch(durationProvider);
return Container(
height: 4,
color: colors?.darkerBackground,
child: Row(
children: [
Flexible(
flex: position,
child: Container(color: colors?.onDarkerBackground),
),
Flexible(flex: duration - position, child: Container()),
],
),
);
}
}

View File

@ -1,152 +0,0 @@
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../database/database.dart';
import '../../models/query.dart';
import '../../models/support.dart';
import '../../services/audio_service.dart';
import '../../state/music.dart';
import '../../state/settings.dart';
import '../app_router.dart';
import '../buttons.dart';
import '../images.dart';
import '../items.dart';
class ArtistPage extends HookConsumerWidget {
final String id;
const ArtistPage({
super.key,
@pathParam required this.id,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen(sourceIdProvider, (_, __) => context.router.popUntilRoot());
final artist = ref.watch(artistProvider(id));
final albums = ref.watch(albumsByArtistIdProvider(id));
return Scaffold(
floatingActionButton: RadioPlayFab(
onPressed: () => artist.hasValue
? ref.read(audioControlProvider).playRadio(
context: QueueContextType.artist,
contextId: artist.valueOrNull!.id,
query: ListQuery(
filters: IList([
FilterWith.equals(
column: 'artist_id',
value: artist.valueOrNull!.id,
)
]),
),
getSongs: (query) => ref
.read(databaseProvider)
.songsList(ref.read(sourceIdProvider), query)
.get(),
)
: null,
),
body: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Stack(
alignment: Alignment.bottomCenter,
fit: StackFit.passthrough,
children: [
ArtistArtImage(
artistId: id,
thumbnail: false,
height: 400,
),
Padding(
padding: const EdgeInsets.all(12),
child: _Title(text: artist.valueOrNull?.name ?? ''),
),
],
),
),
albums.when(
data: (albums) {
albums = albums.sort((a, b) => (b.year ?? 0) - (a.year ?? 0));
return SliverPadding(
padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
sliver: SliverAlignedGrid.count(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 24,
itemCount: albums.length,
itemBuilder: (context, i) {
final album = albums.elementAt(i);
return AlbumCard(
album: album,
subtitle: AlbumSubtitle.year,
onTap: () => context.navigateTo(AlbumSongsRoute(
id: album.id,
)),
);
},
),
);
},
error: (_, __) => SliverToBoxAdapter(
child: Container(color: Colors.red),
),
loading: () => const SliverToBoxAdapter(
child: CircularProgressIndicator(),
),
),
],
),
);
}
}
class _Title extends StatelessWidget {
final String text;
const _Title({
required this.text,
});
@override
Widget build(BuildContext context) {
return Text(
text,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.displayMedium!.copyWith(
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [
Shadow(
offset: Offset.fromDirection(pi / 4, 3),
blurRadius: 16,
color: Colors.black26,
),
Shadow(
offset: Offset.fromDirection(3 * pi / 4, 3),
blurRadius: 16,
color: Colors.black26,
),
Shadow(
offset: Offset.fromDirection(5 * pi / 4, 3),
blurRadius: 16,
color: Colors.black26,
),
Shadow(
offset: Offset.fromDirection(7 * pi / 4, 3),
blurRadius: 16,
color: Colors.black26,
),
],
),
);
}
}

View File

@ -1,216 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../database/database.dart';
import '../../services/settings_service.dart';
import '../../state/settings.dart';
import '../app_router.dart';
import '../now_playing_bar.dart';
part 'bottom_nav_page.g.dart';
@Riverpod(keepAlive: true)
TabObserver bottomTabObserver(BottomTabObserverRef ref) {
return TabObserver();
}
@Riverpod(keepAlive: true)
Stream<String> bottomTabPath(BottomTabPathRef ref) async* {
final observer = ref.watch(bottomTabObserverProvider);
await for (var tab in observer.path) {
yield tab;
}
}
@Riverpod(keepAlive: true)
class LastBottomNavStateService extends _$LastBottomNavStateService {
@override
Future<void> build() async {
final db = ref.watch(databaseProvider);
final tab = ref.watch(bottomTabPathProvider).valueOrNull;
if (tab == null || tab == 'settings' || tab == 'search') {
return;
}
await db.saveLastBottomNavState(LastBottomNavStateData(id: 1, tab: tab));
}
}
class BottomNavTabsPage extends HookConsumerWidget {
const BottomNavTabsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final observer = ref.watch(bottomTabObserverProvider);
const navElevation = 3.0;
return AutoTabsRouter(
lazyLoad: false,
inheritNavigatorObservers: false,
navigatorObservers: () => [observer],
routes: const [
LibraryRouter(),
BrowseRouter(),
SearchRouter(),
SettingsRouter(),
],
builder: (context, child, animation) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light.copyWith(
systemNavigationBarColor: ElevationOverlay.applySurfaceTint(
Theme.of(context).colorScheme.surface,
Theme.of(context).colorScheme.surfaceTint,
navElevation,
),
statusBarColor: Colors.transparent,
),
child: Scaffold(
body: Stack(
alignment: AlignmentDirectional.bottomStart,
children: [
FadeTransition(
opacity: animation,
child: child,
),
const OfflineIndicator(),
],
),
bottomNavigationBar: const _BottomNavBar(
navElevation: navElevation,
),
),
);
},
);
}
}
class OfflineIndicator extends HookConsumerWidget {
const OfflineIndicator({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final offline = ref.watch(offlineModeProvider);
final testing = useState(false);
if (!offline) {
return Container();
}
return Padding(
padding: const EdgeInsetsDirectional.only(
start: 20,
bottom: 20,
),
child: FilledButton.tonal(
style: const ButtonStyle(
padding: MaterialStatePropertyAll<EdgeInsetsGeometry>(
EdgeInsets.zero,
),
fixedSize: MaterialStatePropertyAll<Size>(
Size(42, 42),
),
minimumSize: MaterialStatePropertyAll<Size>(
Size(42, 42),
),
),
onPressed: () async {
testing.value = true;
await ref.read(offlineModeProvider.notifier).setMode(false);
testing.value = false;
},
child: testing.value
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2.5),
)
: const Padding(
padding: EdgeInsets.only(left: 2, bottom: 2),
child: Icon(
Icons.cloud_off_rounded,
// color: Theme.of(context).colorScheme.secondary,
size: 20,
),
),
),
);
}
}
class _BottomNavBar extends HookConsumerWidget {
final double navElevation;
const _BottomNavBar({
required this.navElevation,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabsRouter = AutoTabsRouter.of(context);
useListenableSelector(tabsRouter, () => tabsRouter.activeIndex);
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
const NowPlayingBar(),
NavigationBar(
elevation: navElevation,
height: 50,
labelBehavior: NavigationDestinationLabelBehavior.alwaysHide,
selectedIndex: tabsRouter.activeIndex,
onDestinationSelected: (index) {
// TODO: replace this with a proper first-time setup flow
final hasActiveSource = ref.read(settingsServiceProvider.select(
(value) => value.activeSource != null,
));
if (!hasActiveSource) {
tabsRouter.setActiveIndex(3);
} else {
tabsRouter.setActiveIndex(index);
}
},
destinations: [
const NavigationDestination(
icon: Icon(Icons.music_note),
label: 'Library',
),
NavigationDestination(
icon: Builder(builder: (context) {
return SvgPicture.asset(
'assets/tag_FILL0_wght400_GRAD0_opsz24.svg',
colorFilter: ColorFilter.mode(
IconTheme.of(context).color!.withOpacity(
IconTheme.of(context).opacity ?? 1,
),
BlendMode.srcIn,
),
height: 28,
);
}),
label: 'Browse',
),
const NavigationDestination(
icon: Icon(Icons.search_rounded),
label: 'Search',
),
const NavigationDestination(
icon: Icon(Icons.settings_rounded),
label: 'Settings',
),
],
),
],
);
}
}

View File

@ -1,56 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'bottom_nav_page.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$bottomTabObserverHash() => r'e10c0b870f9b9052ad85fea4342569932edfeefb';
/// See also [bottomTabObserver].
@ProviderFor(bottomTabObserver)
final bottomTabObserverProvider = Provider<TabObserver>.internal(
bottomTabObserver,
name: r'bottomTabObserverProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$bottomTabObserverHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef BottomTabObserverRef = ProviderRef<TabObserver>;
String _$bottomTabPathHash() => r'62539f7bf5b8f7e5f0531f564e634228ba1506bf';
/// See also [bottomTabPath].
@ProviderFor(bottomTabPath)
final bottomTabPathProvider = StreamProvider<String>.internal(
bottomTabPath,
name: r'bottomTabPathProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$bottomTabPathHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef BottomTabPathRef = StreamProviderRef<String>;
String _$lastBottomNavStateServiceHash() =>
r'487cb94cbb70884642c05a72524eb6fd7a4d12ce';
/// See also [LastBottomNavStateService].
@ProviderFor(LastBottomNavStateService)
final lastBottomNavStateServiceProvider =
AsyncNotifierProvider<LastBottomNavStateService, void>.internal(
LastBottomNavStateService.new,
name: r'lastBottomNavStateServiceProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$lastBottomNavStateServiceHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$LastBottomNavStateService = AsyncNotifier<void>;
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions

View File

@ -1,281 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sliver_tools/sliver_tools.dart';
import '../../database/database.dart';
import '../../models/music.dart';
import '../../models/query.dart';
import '../../models/support.dart';
import '../../services/audio_service.dart';
import '../../services/cache_service.dart';
import '../../state/music.dart';
import '../../state/settings.dart';
import '../app_router.dart';
import '../buttons.dart';
import '../images.dart';
import '../items.dart';
part 'browse_page.g.dart';
@riverpod
Stream<List<Album>> albumsCategoryList(
AlbumsCategoryListRef ref,
ListQuery opt,
) {
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
return db.albumsList(sourceId, opt).watch();
}
class BrowsePage extends HookConsumerWidget {
const BrowsePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
final frequent = ref
.watch(albumsCategoryListProvider(const ListQuery(
page: Pagination(limit: 20),
sort: SortBy(column: 'frequent_rank'),
filters: IListConst([
FilterWith.isNull(column: 'frequent_rank', invert: true),
]),
)))
.valueOrNull;
final recent = ref
.watch(albumsCategoryListProvider(const ListQuery(
page: Pagination(limit: 20),
sort: SortBy(column: 'recent_rank'),
filters: IListConst([
FilterWith.isNull(column: 'recent_rank', invert: true),
]),
)))
.valueOrNull;
final starred = ref
.watch(albumsCategoryListProvider(const ListQuery(
page: Pagination(limit: 20),
sort: SortBy(column: 'starred'),
filters: IListConst([
FilterWith.isNull(column: 'starred', invert: true),
]),
)))
.valueOrNull;
final random = ref
.watch(albumsCategoryListProvider(const ListQuery(
page: Pagination(limit: 20),
sort: SortBy(column: 'RANDOM()'),
)))
.valueOrNull;
final genres = ref
.watch(albumGenresProvider(const Pagination(
limit: 20,
)))
.valueOrNull;
return Scaffold(
floatingActionButton: RadioPlayFab(
onPressed: () {
ref.read(audioControlProvider).playRadio(
context: QueueContextType.library,
getSongs: (query) => ref
.read(databaseProvider)
.songsList(ref.read(sourceIdProvider), query)
.get(),
);
},
),
body: CustomScrollView(
slivers: [
const SliverSafeArea(
sliver: SliverPadding(padding: EdgeInsets.only(top: 8)),
),
_GenreCategory(
title: 'Genres',
items: genres?.toList() ?? [],
),
_AlbumCategory(
title: l.resourcesSortByFrequentlyPlayed,
items: frequent ?? [],
),
_AlbumCategory(
title: l.resourcesSortByRecentlyPlayed,
items: recent ?? [],
),
_AlbumCategory(
title: l.resourcesFilterStarred,
items: starred ?? [],
),
_AlbumCategory(
title: l.resourcesSortByRandom,
items: random ?? [],
),
const SliverToBoxAdapter(child: FabPadding()),
],
),
);
}
}
class _GenreCategory extends HookConsumerWidget {
final String title;
final List<String> items;
const _GenreCategory({
required this.title,
required this.items,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SliverPadding(
padding: const EdgeInsets.only(bottom: 16),
sliver: _Category(
title: title,
height: 140,
itemWidth: 140,
items: items.map((genre) => _GenreItem(genre: genre)).toList(),
),
);
}
}
class _GenreItem extends HookConsumerWidget {
final String genre;
const _GenreItem({
required this.genre,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref
.watch(albumsByGenreProvider(
genre,
const Pagination(limit: 4),
))
.valueOrNull;
final cache = ref.watch(cacheServiceProvider);
final theme = Theme.of(context);
if (albums == null) {
return Container();
}
return ImageCard(
onTap: () {
context.navigateTo(GenreSongsRoute(genre: genre));
},
child: Stack(
alignment: AlignmentDirectional.center,
children: [
CardClip(
child: MultiImage(
cacheInfo: albums.map((album) => cache.albumArt(album)),
),
),
Material(
type: MaterialType.canvas,
color: theme.colorScheme.secondaryContainer,
elevation: 5,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
child: SizedBox(
width: double.infinity,
child: Text(
genre,
textAlign: TextAlign.center,
style: theme.textTheme.labelLarge,
),
),
),
),
],
),
);
}
}
class _AlbumCategory extends HookConsumerWidget {
final String title;
final List<Album> items;
const _AlbumCategory({
required this.title,
required this.items,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return _Category(
title: title,
height: 190,
itemWidth: 140,
items: items
.map(
(album) => AlbumCard(
album: album,
onTap: () => context.navigateTo(
AlbumSongsRoute(
id: album.id,
),
),
),
)
.toList(),
);
}
}
class _Category extends HookConsumerWidget {
final String title;
final List<Widget> items;
final double height;
final double itemWidth;
const _Category({
required this.title,
required this.items,
required this.height,
required this.itemWidth,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return MultiSliver(
children: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Text(
title,
style: Theme.of(context).textTheme.headlineMedium,
),
),
),
SliverToBoxAdapter(
child: SizedBox(
height: height,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
scrollDirection: Axis.horizontal,
itemCount: items.length,
itemBuilder: (context, index) => SizedBox(
width: itemWidth,
child: items[index],
),
separatorBuilder: (context, index) => const SizedBox(width: 8),
),
),
),
],
);
}
}

View File

@ -1,114 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'browse_page.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$albumsCategoryListHash() =>
r'e0516a585bf39e8140c72c08fd41f33a817c747d';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
typedef AlbumsCategoryListRef = AutoDisposeStreamProviderRef<List<Album>>;
/// See also [albumsCategoryList].
@ProviderFor(albumsCategoryList)
const albumsCategoryListProvider = AlbumsCategoryListFamily();
/// See also [albumsCategoryList].
class AlbumsCategoryListFamily extends Family<AsyncValue<List<Album>>> {
/// See also [albumsCategoryList].
const AlbumsCategoryListFamily();
/// See also [albumsCategoryList].
AlbumsCategoryListProvider call(
ListQuery opt,
) {
return AlbumsCategoryListProvider(
opt,
);
}
@override
AlbumsCategoryListProvider getProviderOverride(
covariant AlbumsCategoryListProvider provider,
) {
return call(
provider.opt,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'albumsCategoryListProvider';
}
/// See also [albumsCategoryList].
class AlbumsCategoryListProvider
extends AutoDisposeStreamProvider<List<Album>> {
/// See also [albumsCategoryList].
AlbumsCategoryListProvider(
this.opt,
) : super.internal(
(ref) => albumsCategoryList(
ref,
opt,
),
from: albumsCategoryListProvider,
name: r'albumsCategoryListProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$albumsCategoryListHash,
dependencies: AlbumsCategoryListFamily._dependencies,
allTransitiveDependencies:
AlbumsCategoryListFamily._allTransitiveDependencies,
);
final ListQuery opt;
@override
bool operator ==(Object other) {
return other is AlbumsCategoryListProvider && other.opt == opt;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, opt.hashCode);
return _SystemHash.finish(hash);
}
}
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions

View File

@ -1,41 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../database/database.dart';
import '../../state/settings.dart';
import '../app_router.dart';
import '../hooks/use_list_query_paging_controller.dart';
import '../items.dart';
import '../lists.dart';
class LibraryAlbumsPage extends HookConsumerWidget {
const LibraryAlbumsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pagingController = useLibraryPagingController(
ref,
libraryTabIndex: 0,
getItems: (query) {
final db = ref.read(databaseProvider);
final sourceId = ref.read(sourceIdProvider);
return ref.read(offlineModeProvider)
? db.albumsListDownloaded(sourceId, query).get()
: db.albumsList(sourceId, query).get();
},
);
return PagedGridQueryView(
pagingController: pagingController,
refreshSyncAll: true,
itemBuilder: (context, item, index, size) => AlbumCard(
album: item,
style:
size == GridSize.small ? CardStyle.imageOnly : CardStyle.withText,
onTap: () => context.navigateTo(AlbumSongsRoute(id: item.id)),
),
);
}
}

View File

@ -1,39 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../database/database.dart';
import '../../state/settings.dart';
import '../app_router.dart';
import '../hooks/use_list_query_paging_controller.dart';
import '../items.dart';
import '../lists.dart';
class LibraryArtistsPage extends HookConsumerWidget {
const LibraryArtistsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pagingController = useLibraryPagingController(
ref,
libraryTabIndex: 1,
getItems: (query) {
final db = ref.read(databaseProvider);
final sourceId = ref.read(sourceIdProvider);
return ref.read(offlineModeProvider)
? db.artistsListDownloaded(sourceId, query).get()
: db.artistsList(sourceId, query).get();
},
);
return PagedListQueryView(
pagingController: pagingController,
refreshSyncAll: true,
itemBuilder: (context, item, index) => ArtistListTile(
artist: item,
onTap: () => context.navigateTo(ArtistRoute(id: item.id)),
),
);
}
}

View File

@ -1,635 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../database/database.dart';
import '../../models/query.dart';
import '../app_router.dart';
import '../context_menus.dart';
part 'library_page.g.dart';
@Riverpod(keepAlive: true)
TabObserver libraryTabObserver(LibraryTabObserverRef ref) {
return TabObserver();
}
@Riverpod(keepAlive: true)
Stream<String> libraryTabPath(LibraryTabPathRef ref) async* {
final observer = ref.watch(libraryTabObserverProvider);
await for (var tab in observer.path) {
yield tab;
}
}
@Riverpod(keepAlive: true)
class LastLibraryStateService extends _$LastLibraryStateService {
@override
Future<void> build() async {
final db = ref.watch(databaseProvider);
final tab = await ref.watch(libraryTabPathProvider.future);
await db.saveLastLibraryState(LastLibraryStateData(
id: 1,
tab: tab,
albumsList: ref.watch(libraryListQueryProvider(0)).query,
artistsList: ref.watch(libraryListQueryProvider(1)).query,
playlistsList: ref.watch(libraryListQueryProvider(2)).query,
songsList: ref.watch(libraryListQueryProvider(3)).query,
));
}
}
@Riverpod(keepAlive: true)
class LibraryLists extends _$LibraryLists {
@override
IList<LibraryListQuery> build() {
return const IListConst([
/// Albums
LibraryListQuery(
options: ListQueryOptions(
sortColumns: IListConst([
'albums.name',
'albums.created',
'albums.album_artist',
'albums.year',
]),
filterColumns: IListConst([
'albums.starred',
'albums.album_artist',
'albums.year',
'albums.genre',
]),
),
query: ListQuery(
page: Pagination(limit: 60),
sort: SortBy(column: 'albums.name'),
),
),
/// Artists
LibraryListQuery(
options: ListQueryOptions(
sortColumns: IListConst([
'artists.name',
'artists.album_count',
]),
filterColumns: IListConst([
'artists.starred',
]),
),
query: ListQuery(
page: Pagination(limit: 30),
sort: SortBy(column: 'artists.name'),
),
),
/// Playlists
LibraryListQuery(
options: ListQueryOptions(
sortColumns: IListConst([
'playlists.name',
'playlists.created',
'playlists.changed',
]),
filterColumns: IListConst([
'playlists.owner',
]),
),
query: ListQuery(
page: Pagination(limit: 30),
sort: SortBy(column: 'playlists.name'),
),
),
/// Songs
LibraryListQuery(
options: ListQueryOptions(
sortColumns: IListConst([
'songs.album',
'songs.artist',
'songs.created',
'songs.title',
'songs.year',
]),
filterColumns: IListConst([
'songs.starred',
'songs.artist',
'songs.album',
'songs.year',
'songs.genre',
]),
),
query: ListQuery(
page: Pagination(limit: 30),
sort: SortBy(column: 'songs.album'),
),
),
]);
}
Future<void> init() async {
final db = ref.read(databaseProvider);
final last = await db.getLastLibraryState().getSingleOrNull();
if (last == null) {
return;
}
state = state
.replace(0, state[0].copyWith(query: last.albumsList))
.replace(1, state[1].copyWith(query: last.artistsList))
.replace(2, state[2].copyWith(query: last.playlistsList))
.replace(3, state[3].copyWith(query: last.songsList));
}
void setSortColumn(int index, String column) {
state = state.replace(
index,
state[index].copyWith.query.sort!(column: column),
);
}
void toggleDirection(int index) {
final toggled = state[index].query.sort?.dir == SortDirection.asc
? SortDirection.desc
: SortDirection.asc;
state = state.replace(
index,
state[index].copyWith.query.sort!(dir: toggled),
);
}
void setFilter(int index, FilterWith filter) {
state = state.replace(
index,
state[index].copyWith.query(
filters: state[index].query.filters.updateById(
[filter],
(e) => e.column,
),
),
);
}
void removeFilter(int index, String column) {
state = state.replace(
index,
state[index].copyWith.query(
filters: state[index]
.query
.filters
.removeWhere((f) => f.column == column)),
);
}
void clearFilters(int index) {
state = state.replace(index, state[index].copyWith.query(filters: IList()));
}
}
@Riverpod(keepAlive: true)
LibraryListQuery libraryListQuery(LibraryListQueryRef ref, int index) {
return ref.watch(libraryListsProvider.select((value) => value[index]));
}
class LibraryTabsPage extends HookConsumerWidget {
const LibraryTabsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final observer = ref.watch(libraryTabObserverProvider);
return AutoTabsRouter.tabBar(
inheritNavigatorObservers: false,
navigatorObservers: () => [observer],
routes: const [
LibraryAlbumsRoute(),
LibraryArtistsRoute(),
LibraryPlaylistsRoute(),
LibrarySongsRoute(),
],
builder: (context, child, tabController) {
return Scaffold(
body: child,
floatingActionButton: const _LibraryFilterFab(),
);
},
);
}
}
class _LibraryFilterFab extends HookConsumerWidget {
const _LibraryFilterFab();
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabsRouter = AutoTabsRouter.of(context);
final activeIndex =
useListenableSelector(tabsRouter, () => tabsRouter.activeIndex);
final tabHasFilters = ref.watch(libraryListQueryProvider(activeIndex)
.select((value) => value.query.filters.isNotEmpty));
List<Widget> dot = [];
if (tabHasFilters) {
dot.addAll([
PositionedDirectional(
top: 3,
end: 0,
child: Icon(
Icons.circle,
color: Theme.of(context).colorScheme.primaryContainer,
size: 11,
),
),
const PositionedDirectional(
top: 5,
end: 1,
child: Icon(
Icons.circle,
size: 7,
),
),
]);
}
return FloatingActionButton(
heroTag: null,
onPressed: () async {
showContextMenu(
context: context,
ref: ref,
builder: (context) => BottomSheetMenu(
child: LibraryMenu(
tabsRouter: tabsRouter,
),
),
);
},
tooltip: 'List',
child: Stack(
children: [
const Icon(
Icons.sort_rounded,
size: 28,
),
...dot,
],
),
);
}
}
class LibraryMenu extends HookConsumerWidget {
final TabsRouter tabsRouter;
const LibraryMenu({
super.key,
required this.tabsRouter,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
sliver: SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Row(
children: [
FilterChip(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(100),
),
onSelected: (value) {},
selected: true,
label: const Icon(
Icons.grid_on,
size: 20,
),
),
const SizedBox(width: 6),
FilterChip(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(100),
),
onSelected: (value) {},
label: const Icon(
Icons.grid_view_outlined,
size: 20,
),
),
const SizedBox(width: 6),
FilterChip(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(100),
),
onSelected: (value) {},
label: const Icon(
Icons.format_list_bulleted,
size: 20,
),
),
],
),
_FilterToggleButton(tabsRouter: tabsRouter),
],
),
),
),
const SliverPadding(padding: EdgeInsets.only(top: 8)),
ListSortFilterOptions(index: tabsRouter.activeIndex),
const SliverPadding(padding: EdgeInsets.only(top: 16)),
],
);
}
}
class _FilterToggleButton extends HookConsumerWidget {
final TabsRouter tabsRouter;
const _FilterToggleButton({
required this.tabsRouter,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabHasFilters = ref.watch(
libraryListQueryProvider(tabsRouter.activeIndex)
.select((value) => value.query.filters.isNotEmpty));
return FilledButton(
onPressed: tabHasFilters
? () {
ref
.read(libraryListsProvider.notifier)
.clearFilters(tabsRouter.activeIndex);
}
: null,
child: const Icon(Icons.filter_list_off_rounded),
);
}
}
class ListSortFilterOptions extends HookConsumerWidget {
final int index;
const ListSortFilterOptions({
super.key,
required this.index,
});
void Function()? _filterOnEdit(
String column,
BuildContext context,
WidgetRef ref,
) {
final type = column.split('.').last;
switch (type) {
case 'year':
return () {
// TODO: year filter dialog
// showDialog(
// context: context,
// builder: (context) {
// return Dialog(
// child: Text('adsf'),
// );
// },
// );
};
case 'genre':
case 'album_artist':
case 'owner':
case 'album':
case 'artist':
// TODO: other filter dialogs
return () {};
default:
return null;
}
}
void Function(bool? value)? _filterOnChanged(String column, WidgetRef ref) {
final type = column.split('.').last;
switch (type) {
case 'starred':
return (value) {
if (value!) {
ref.read(libraryListsProvider.notifier).setFilter(
index,
FilterWith.isNull(column: column, invert: true),
);
} else {
ref.read(libraryListsProvider.notifier).removeFilter(index, column);
}
};
case 'year':
// TODO: add/remove filter
return null;
case 'genre':
case 'album_artist':
case 'owner':
case 'album':
case 'artist':
// TODO: add/remove filter
return null;
default:
return null;
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final list = ref.watch(libraryListQueryProvider(index));
return SliverList(
delegate: SliverChildListDelegate.fixed([
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Sort by',
style: Theme.of(context).textTheme.titleLarge,
),
),
const SizedBox(height: 8),
for (var column in list.options.sortColumns)
SortOptionTile(
column: column,
value: list.query.sort!.copyWith(column: column),
groupValue: list.query.sort!,
onColumnChanged: (column) {
if (column != null) {
ref
.read(libraryListsProvider.notifier)
.setSortColumn(index, column);
}
},
onDirectionToggle: () =>
ref.read(libraryListsProvider.notifier).toggleDirection(index),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Filter',
style: Theme.of(context).textTheme.titleLarge,
),
),
for (var column in list.options.filterColumns)
FilterOptionTile(
column: column,
state: list.query.filters.singleWhereOrNull(
(e) => e.column == column,
),
onEdit: _filterOnEdit(column, context, ref),
onChanged: _filterOnChanged(column, ref),
)
]),
);
}
}
class SortOptionTile extends HookConsumerWidget {
final String column;
final SortBy value;
final SortBy groupValue;
final void Function(String? value) onColumnChanged;
final void Function() onDirectionToggle;
const SortOptionTile({
super.key,
required this.column,
required this.value,
required this.groupValue,
required this.onColumnChanged,
required this.onDirectionToggle,
});
String _sortTitle(AppLocalizations l, String type) {
type = type.split('.').last;
switch (type) {
case 'name':
return l.resourcesSortByName;
case 'album_artist':
return l.resourcesSortByArtist;
case 'created':
return l.resourcesSortByAdded;
case 'year':
return l.resourcesSortByYear;
case 'album_count':
return l.resourcesSortByAlbumCount;
case 'changed':
return l.resourcesSortByUpdated;
case 'album':
return l.resourcesSortByAlbum;
case 'artist':
return l.resourcesSortByArtist;
case 'title':
return l.resourcesSortByTitle;
default:
return '';
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
return RadioListTile<String?>(
value: value.column,
groupValue: groupValue.column,
onChanged: onColumnChanged,
selected: value.column == groupValue.column,
title: Text(_sortTitle(l, column)),
secondary: value.column == groupValue.column
? IconButton(
icon: Icon(
value.dir == SortDirection.desc
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
),
onPressed: onDirectionToggle,
)
: null,
);
}
}
class FilterOptionTile extends HookConsumerWidget {
final String column;
final FilterWith? state;
final void Function(bool? value)? onChanged;
final void Function()? onEdit;
const FilterOptionTile({
super.key,
required this.column,
required this.state,
required this.onChanged,
this.onEdit,
});
String _filterTitle(AppLocalizations l, String type) {
type = type.split('.').last;
switch (type) {
case 'starred':
return l.resourcesFilterStarred;
case 'year':
return l.resourcesFilterYear;
case 'genre':
return l.resourcesFilterGenre;
case 'album_artist':
return l.resourcesFilterArtist;
case 'owner':
return l.resourcesFilterOwner;
case 'album':
return l.resourcesFilterAlbum;
case 'artist':
return l.resourcesFilterArtist;
default:
return '';
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
return CheckboxListTile(
value: state == null
? false
: state!.map(
equals: (value) => value.invert ? null : true,
greaterThan: (value) => true,
isNull: (_) => true,
betweenInt: (_) => true,
isIn: (value) => value.invert ? null : true,
),
tristate: state?.map(
equals: (value) => true,
greaterThan: (value) => false,
isNull: (_) => false,
betweenInt: (_) => false,
isIn: (_) => true,
) ??
false,
title: Text(_filterTitle(l, column)),
secondary: onEdit == null
? null
: IconButton(
icon: const Icon(Icons.edit_rounded),
onPressed: onEdit,
),
controlAffinity: ListTileControlAffinity.leading,
onChanged: onChanged,
);
}
}

View File

@ -1,176 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'library_page.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$libraryTabObserverHash() =>
r'a976ea55e2168e4684114c47592f25a2b187f15f';
/// See also [libraryTabObserver].
@ProviderFor(libraryTabObserver)
final libraryTabObserverProvider = Provider<TabObserver>.internal(
libraryTabObserver,
name: r'libraryTabObserverProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$libraryTabObserverHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef LibraryTabObserverRef = ProviderRef<TabObserver>;
String _$libraryTabPathHash() => r'fe60984ea9d629683d344f809749b1b9362735fa';
/// See also [libraryTabPath].
@ProviderFor(libraryTabPath)
final libraryTabPathProvider = StreamProvider<String>.internal(
libraryTabPath,
name: r'libraryTabPathProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$libraryTabPathHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef LibraryTabPathRef = StreamProviderRef<String>;
String _$libraryListQueryHash() => r'6079338e19e0249aaa09868dd405fd3aefc42c2b';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
typedef LibraryListQueryRef = ProviderRef<LibraryListQuery>;
/// See also [libraryListQuery].
@ProviderFor(libraryListQuery)
const libraryListQueryProvider = LibraryListQueryFamily();
/// See also [libraryListQuery].
class LibraryListQueryFamily extends Family<LibraryListQuery> {
/// See also [libraryListQuery].
const LibraryListQueryFamily();
/// See also [libraryListQuery].
LibraryListQueryProvider call(
int index,
) {
return LibraryListQueryProvider(
index,
);
}
@override
LibraryListQueryProvider getProviderOverride(
covariant LibraryListQueryProvider provider,
) {
return call(
provider.index,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'libraryListQueryProvider';
}
/// See also [libraryListQuery].
class LibraryListQueryProvider extends Provider<LibraryListQuery> {
/// See also [libraryListQuery].
LibraryListQueryProvider(
this.index,
) : super.internal(
(ref) => libraryListQuery(
ref,
index,
),
from: libraryListQueryProvider,
name: r'libraryListQueryProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$libraryListQueryHash,
dependencies: LibraryListQueryFamily._dependencies,
allTransitiveDependencies:
LibraryListQueryFamily._allTransitiveDependencies,
);
final int index;
@override
bool operator ==(Object other) {
return other is LibraryListQueryProvider && other.index == index;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, index.hashCode);
return _SystemHash.finish(hash);
}
}
String _$lastLibraryStateServiceHash() =>
r'a49e26b5dc0fcb0f697ec2def08e7336f64c4cb3';
/// See also [LastLibraryStateService].
@ProviderFor(LastLibraryStateService)
final lastLibraryStateServiceProvider =
AsyncNotifierProvider<LastLibraryStateService, void>.internal(
LastLibraryStateService.new,
name: r'lastLibraryStateServiceProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$lastLibraryStateServiceHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$LastLibraryStateService = AsyncNotifier<void>;
String _$libraryListsHash() => r'7c9fd1ca3b0d70253e0f5d8197abf18b3a18c995';
/// See also [LibraryLists].
@ProviderFor(LibraryLists)
final libraryListsProvider =
NotifierProvider<LibraryLists, IList<LibraryListQuery>>.internal(
LibraryLists.new,
name: r'libraryListsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$libraryListsHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$LibraryLists = Notifier<IList<LibraryListQuery>>;
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions

View File

@ -1,39 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../database/database.dart';
import '../../state/settings.dart';
import '../app_router.dart';
import '../hooks/use_list_query_paging_controller.dart';
import '../items.dart';
import '../lists.dart';
class LibraryPlaylistsPage extends HookConsumerWidget {
const LibraryPlaylistsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pagingController = useLibraryPagingController(
ref,
libraryTabIndex: 2,
getItems: (query) {
final db = ref.read(databaseProvider);
final sourceId = ref.read(sourceIdProvider);
return ref.read(offlineModeProvider)
? db.playlistsListDownloaded(sourceId, query).get()
: db.playlistsList(sourceId, query).get();
},
);
return PagedListQueryView(
pagingController: pagingController,
refreshSyncAll: true,
itemBuilder: (context, item, index) => PlaylistListTile(
playlist: item,
onTap: () => context.navigateTo(PlaylistSongsRoute(id: item.id)),
),
);
}
}

View File

@ -1,81 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../database/database.dart';
import '../../models/music.dart';
import '../../models/query.dart';
import '../../models/support.dart';
import '../../services/audio_service.dart';
import '../../state/settings.dart';
import '../hooks/use_list_query_paging_controller.dart';
import '../items.dart';
import '../lists.dart';
import 'library_page.dart';
import 'songs_page.dart';
part 'library_songs_page.g.dart';
@riverpod
Future<List<Song>> songsList(SongsListRef ref, ListQuery opt) {
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
return db.songsList(sourceId, opt).get();
}
class LibrarySongsPage extends HookConsumerWidget {
const LibrarySongsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final audio = ref.watch(audioControlProvider);
final query = ref.watch(libraryListQueryProvider(3).select(
(value) => value.query,
));
final getSongs = useCallback(
(ListQuery query) {
final db = ref.read(databaseProvider);
final sourceId = ref.read(sourceIdProvider);
return ref.read(offlineModeProvider)
? db.songsListDownloaded(sourceId, query).get()
: db.songsList(sourceId, query).get();
},
[],
);
final play = useCallback(
({int? index, bool? shuffle}) => audio.playSongs(
query: query,
getSongs: getSongs,
startIndex: index,
context: QueueContextType.song,
shuffle: shuffle,
),
[query, getSongs],
);
final pagingController = useLibraryPagingController(
ref,
libraryTabIndex: 3,
getItems: getSongs,
);
return PagedListQueryView(
pagingController: pagingController,
refreshSyncAll: true,
itemBuilder: (context, item, index) => QueueContext(
type: QueueContextType.song,
child: SongListTile(
song: item,
image: true,
onTap: () => play(index: index),
),
),
);
}
}

View File

@ -1,111 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'library_songs_page.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$songsListHash() => r'a3149eb61f8f1ff326e9b1de0ac1c02d7baa831f';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
typedef SongsListRef = AutoDisposeFutureProviderRef<List<Song>>;
/// See also [songsList].
@ProviderFor(songsList)
const songsListProvider = SongsListFamily();
/// See also [songsList].
class SongsListFamily extends Family<AsyncValue<List<Song>>> {
/// See also [songsList].
const SongsListFamily();
/// See also [songsList].
SongsListProvider call(
ListQuery opt,
) {
return SongsListProvider(
opt,
);
}
@override
SongsListProvider getProviderOverride(
covariant SongsListProvider provider,
) {
return call(
provider.opt,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'songsListProvider';
}
/// See also [songsList].
class SongsListProvider extends AutoDisposeFutureProvider<List<Song>> {
/// See also [songsList].
SongsListProvider(
this.opt,
) : super.internal(
(ref) => songsList(
ref,
opt,
),
from: songsListProvider,
name: r'songsListProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$songsListHash,
dependencies: SongsListFamily._dependencies,
allTransitiveDependencies: SongsListFamily._allTransitiveDependencies,
);
final ListQuery opt;
@override
bool operator ==(Object other) {
return other is SongsListProvider && other.opt == opt;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, opt.hashCode);
return _SystemHash.finish(hash);
}
}
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions

View File

@ -1,429 +0,0 @@
import 'dart:math';
import 'package:audio_service/audio_service.dart';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:text_scroll/text_scroll.dart';
import '../../cache/image_cache.dart';
import '../../models/support.dart';
import '../../services/audio_service.dart';
import '../../state/audio.dart';
import '../../state/theme.dart';
import '../context_menus.dart';
import '../gradient.dart';
import '../images.dart';
import '../now_playing_bar.dart';
class NowPlayingPage extends HookConsumerWidget {
const NowPlayingPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = ref.watch(mediaItemThemeProvider).valueOrNull;
final itemData = ref.watch(mediaItemDataProvider);
final theme = Theme.of(context);
final scaffold = AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light.copyWith(
systemNavigationBarColor: colors?.gradientLow,
statusBarColor: Colors.transparent,
),
child: Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
itemData?.contextType.value ?? '',
style: theme.textTheme.labelMedium,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.fade,
),
// Text(
// itemData?.contextTitle ?? '',
// style: theme.textTheme.titleMedium,
// maxLines: 1,
// softWrap: false,
// overflow: TextOverflow.fade,
// ),
],
),
),
body: Stack(
children: [
const MediaItemGradient(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Column(
children: const [
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: _Art(),
),
),
SizedBox(height: 24),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: _TrackInfo(),
),
SizedBox(height: 8),
_Progress(),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: _Controls(),
),
SizedBox(height: 64),
],
),
),
],
),
),
);
if (colors != null) {
return Theme(data: colors.theme, child: scaffold);
} else {
return scaffold;
}
}
}
class _Art extends HookConsumerWidget {
const _Art();
@override
Widget build(BuildContext context, WidgetRef ref) {
final itemData = ref.watch(mediaItemDataProvider);
final imageCache = ref.watch(imageCacheProvider);
UriCacheInfo? cacheInfo;
if (itemData?.artCache != null) {
cacheInfo = UriCacheInfo(
uri: itemData!.artCache!.fullArtUri,
cacheKey: itemData.artCache!.fullArtCacheKey,
cacheManager: imageCache,
);
}
return AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
child: CardClip(
key: ValueKey(cacheInfo?.cacheKey ?? 'default'),
child: cacheInfo != null
? CardClip(
square: false,
child: UriCacheInfoImage(
// height: 300,
fit: BoxFit.contain,
placeholderStyle: PlaceholderStyle.spinner,
cache: cacheInfo,
),
)
: const PlaceholderImage(thumbnail: false),
),
);
}
}
class _TrackInfo extends HookConsumerWidget {
const _TrackInfo();
@override
Widget build(BuildContext context, WidgetRef ref) {
final item = ref.watch(mediaItemProvider).valueOrNull;
final theme = Theme.of(context);
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ScrollableText(
item?.title ?? '',
style: theme.textTheme.headlineSmall,
speed: 50,
),
Text(
item?.artist ?? '',
style: theme.textTheme.titleMedium!,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.fade,
),
],
),
),
IconButton(
icon: const Icon(
Icons.star_outline_rounded,
size: 36,
),
onPressed: () {},
),
],
);
}
}
class ScrollableText extends StatelessWidget {
final String text;
final TextStyle? style;
final double speed;
const ScrollableText(
this.text, {
super.key,
this.style,
this.speed = 35,
});
@override
Widget build(BuildContext context) {
final defaultStyle = DefaultTextStyle.of(context);
return AutoSizeText(
text,
presetFontSizes: style != null && style?.fontSize != null
? [style!.fontSize!]
: [defaultStyle.style.fontSize ?? 12],
style: style,
maxLines: 1,
// softWrap: false,
overflowReplacement: TextScroll(
'$text ',
style: style,
delayBefore: const Duration(seconds: 3),
pauseBetween: const Duration(seconds: 4),
mode: TextScrollMode.endless,
velocity: Velocity(pixelsPerSecond: Offset(speed, 0)),
),
);
}
}
class _Progress extends HookConsumerWidget {
const _Progress();
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = ref.watch(mediaItemThemeProvider).valueOrNull;
final position = ref.watch(positionProvider);
final duration = ref.watch(durationProvider);
final audio = ref.watch(audioControlProvider);
final changeValue = useState(position.toDouble());
final changing = useState(false);
return Column(
children: [
Slider(
value: changing.value ? changeValue.value : position.toDouble(),
min: 0,
max: max(duration.toDouble(), position.toDouble()),
thumbColor: colors?.theme.colorScheme.onBackground,
activeColor: colors?.theme.colorScheme.onBackground,
inactiveColor: colors?.theme.colorScheme.surface,
onChanged: (value) {
changeValue.value = value;
},
onChangeStart: (value) {
changing.value = true;
},
onChangeEnd: (value) {
changing.value = false;
audio.seek(Duration(seconds: value.toInt()));
},
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: DefaultTextStyle(
style: Theme.of(context).textTheme.titleMedium!,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(Duration(
seconds: changing.value
? changeValue.value.toInt()
: position)
.toString()
.substring(2, 7)),
Text(Duration(seconds: duration).toString().substring(2, 7)),
],
),
),
)
],
);
}
}
class RepeatButton extends HookConsumerWidget {
final double size;
const RepeatButton({
super.key,
required this.size,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final audio = ref.watch(audioControlProvider);
final repeat = ref.watch(repeatModeProvider);
IconData icon;
void Function() action;
switch (repeat) {
case AudioServiceRepeatMode.all:
case AudioServiceRepeatMode.group:
icon = Icons.repeat_on_rounded;
action = () => audio.setRepeatMode(AudioServiceRepeatMode.one);
break;
case AudioServiceRepeatMode.one:
icon = Icons.repeat_one_on_rounded;
action = () => audio.setRepeatMode(AudioServiceRepeatMode.none);
break;
default:
icon = Icons.repeat_rounded;
action = () => audio.setRepeatMode(AudioServiceRepeatMode.all);
break;
}
return IconButton(
icon: Icon(icon),
padding: EdgeInsets.zero,
iconSize: 30,
onPressed: action,
);
}
}
class ShuffleButton extends HookConsumerWidget {
final double size;
const ShuffleButton({
super.key,
required this.size,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final audio = ref.watch(audioControlProvider);
final shuffle = ref.watch(shuffleModeProvider);
final queueMode = ref.watch(queueModeProvider).valueOrNull;
IconData icon;
void Function() action;
switch (shuffle) {
case AudioServiceShuffleMode.all:
case AudioServiceShuffleMode.group:
icon = Icons.shuffle_on_rounded;
action = () => audio.setShuffleMode(AudioServiceShuffleMode.none);
break;
default:
icon = Icons.shuffle_rounded;
action = () => audio.setShuffleMode(AudioServiceShuffleMode.all);
break;
}
return IconButton(
icon: Icon(queueMode == QueueMode.radio ? Icons.radio_rounded : icon),
padding: EdgeInsets.zero,
iconSize: 30,
onPressed: queueMode == QueueMode.radio ? null : action,
);
}
}
class _Controls extends HookConsumerWidget {
const _Controls();
@override
Widget build(BuildContext context, WidgetRef ref) {
final base = ref.watch(baseThemeProvider);
final audio = ref.watch(audioControlProvider);
return IconTheme(
data: IconThemeData(color: base.theme.colorScheme.onBackground),
child: Column(
children: [
SizedBox(
height: 100,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const RepeatButton(size: 30),
IconButton(
icon: const Icon(Icons.skip_previous_rounded),
padding: EdgeInsets.zero,
iconSize: 60,
onPressed: () => audio.skipToPrevious(),
),
const PlayPauseButton(size: 90),
IconButton(
icon: const Icon(Icons.skip_next_rounded),
padding: EdgeInsets.zero,
iconSize: 60,
onPressed: () => audio.skipToNext(),
),
const ShuffleButton(size: 30),
],
),
),
SizedBox(
height: 40,
child: Row(
// crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.queue_music_rounded),
padding: EdgeInsets.zero,
iconSize: 30,
onPressed: () {},
),
const _MoreButton(),
],
),
),
],
),
);
}
}
class _MoreButton extends HookConsumerWidget {
const _MoreButton();
@override
Widget build(BuildContext context, WidgetRef ref) {
final song = ref.watch(mediaItemSongProvider).valueOrNull;
return IconButton(
icon: const Icon(Icons.more_horiz),
padding: EdgeInsets.zero,
iconSize: 30,
onPressed: song != null
? () {
showContextMenu(
context: context,
ref: ref,
builder: (context) => BottomSheetMenu(
child: SongContextMenu(song: song),
),
);
}
: null,
);
}
}

View File

@ -1,247 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../database/database.dart';
import '../../models/music.dart';
import '../../models/query.dart';
import '../../models/support.dart';
import '../../services/audio_service.dart';
import '../../state/music.dart';
import '../../state/settings.dart';
import '../app_router.dart';
import '../items.dart';
import 'songs_page.dart';
part 'search_page.g.dart';
@riverpod
class SearchQuery extends _$SearchQuery {
@override
String? build() {
return null;
}
void setQuery(String query) {
state = query;
}
}
@riverpod
FutureOr<SearchResults> searchResult(SearchResultRef ref) async {
final query = ref.watch(searchQueryProvider);
final db = ref.watch(databaseProvider);
final sourceId = ref.watch(sourceIdProvider);
final ftsQuery = '(source_id : $sourceId) AND (- source_id : "$query"*)';
final songRowIds = await db.searchSongs(ftsQuery, 5, 0).get();
final songs = await db.songsInRowIds(songRowIds).get();
final albumRowIds = await db.searchAlbums(ftsQuery, 5, 0).get();
final albums = await db.albumsInRowIds(albumRowIds).get();
final artistRowIds = await db.searchArtists(ftsQuery, 5, 0).get();
final artists = await db.artistsInRowIds(artistRowIds).get();
return SearchResults(
songs: songs.lock,
albums: albums.lock,
artists: artists.lock,
);
}
class SearchPage extends HookConsumerWidget {
const SearchPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final results = ref.watch(searchResultProvider).valueOrNull;
return KeyboardDismissOnTap(
dismissOnCapturedTaps: true,
child: Scaffold(
body: SafeArea(
child: CustomScrollView(
reverse: true,
slivers: [
const SliverToBoxAdapter(child: _SearchBar()),
if (results != null && results.songs.isNotEmpty)
_SongsSection(songs: results.songs),
if (results != null && results.albums.isNotEmpty)
_AlbumsSection(albums: results.albums),
if (results != null && results.artists.isNotEmpty)
_ArtistsSection(artists: results.artists),
if (results != null)
const SliverPadding(padding: EdgeInsets.only(top: 96))
],
),
),
),
);
}
}
class _SearchBar extends HookConsumerWidget {
const _SearchBar();
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useTextEditingController(text: '');
final theme = Theme.of(context);
final l = AppLocalizations.of(context);
return Container(
color: ElevationOverlay.applySurfaceTint(
theme.colorScheme.surface,
theme.colorScheme.surfaceTint,
1,
),
child: Padding(
padding: const EdgeInsets.only(
right: 24,
left: 24,
bottom: 24,
top: 8,
),
child: IgnoreKeyboardDismiss(
child: TextFormField(
controller: controller,
decoration: InputDecoration(
hintText: l.searchInputPlaceholder,
),
onChanged: (value) {
ref.read(searchQueryProvider.notifier).setQuery(value);
},
),
),
),
);
}
}
class _SectionHeader extends HookConsumerWidget {
final String title;
const _SectionHeader({required this.title});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(title, style: theme.headlineMedium),
);
}
}
class _Section extends HookConsumerWidget {
final String title;
final Iterable<Widget> children;
const _Section({
required this.title,
required this.children,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SliverList(
delegate: SliverChildListDelegate([
const SizedBox(height: 16),
...children.toList().reversed,
_SectionHeader(title: title),
]),
);
}
}
class _SongsSection extends HookConsumerWidget {
final IList<Song>? songs;
const _SongsSection({required this.songs});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
return _Section(
title: l.resourcesSongName(100),
children: (songs ?? <Song>[]).map(
(song) => QueueContext(
type: QueueContextType.album,
id: song.albumId!,
child: SongListTile(
song: song,
image: true,
onTap: () async {
const query = ListQuery(
sort: SortBy(column: 'disc, track'),
);
final albumSongs = await ref.read(
albumSongsListProvider(song.albumId!, query).future,
);
ref.read(audioControlProvider).playSongs(
context: QueueContextType.album,
contextId: song.albumId!,
shuffle: true,
startIndex: albumSongs.indexOf(song),
query: query,
getSongs: (query) => ref.read(
albumSongsListProvider(song.albumId!, query).future),
);
},
),
),
),
);
}
}
class _AlbumsSection extends HookConsumerWidget {
final IList<Album>? albums;
const _AlbumsSection({required this.albums});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
return _Section(
title: l.resourcesAlbumName(100),
children: (albums ?? <Album>[]).map(
(album) => AlbumListTile(
album: album,
onTap: () => context.navigateTo(AlbumSongsRoute(id: album.id)),
),
),
);
}
}
class _ArtistsSection extends HookConsumerWidget {
final IList<Artist>? artists;
const _ArtistsSection({required this.artists});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
return _Section(
title: l.resourcesArtistName(100),
children: (artists ?? <Artist>[]).map(
(artist) => ArtistListTile(
artist: artist,
onTap: () => context.navigateTo(ArtistRoute(id: artist.id)),
),
),
);
}
}

View File

@ -1,38 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'search_page.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$searchResultHash() => r'e5240c0c51937e1e946138d27aeaea93dc0231c3';
/// See also [searchResult].
@ProviderFor(searchResult)
final searchResultProvider = AutoDisposeFutureProvider<SearchResults>.internal(
searchResult,
name: r'searchResultProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$searchResultHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef SearchResultRef = AutoDisposeFutureProviderRef<SearchResults>;
String _$searchQueryHash() => r'f7624215b3d5a8b917cb0af239666a19a18d91d5';
/// See also [SearchQuery].
@ProviderFor(SearchQuery)
final searchQueryProvider =
AutoDisposeNotifierProvider<SearchQuery, String?>.internal(
SearchQuery.new,
name: r'searchQueryProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$searchQueryHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$SearchQuery = AutoDisposeNotifier<String?>;
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions

View File

@ -1,448 +0,0 @@
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:path/path.dart' as p;
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../log.dart';
import '../../models/support.dart';
import '../../services/settings_service.dart';
import '../../state/init.dart';
import '../../state/settings.dart';
import '../app_router.dart';
import '../dialogs.dart';
const kHorizontalPadding = 16.0;
class SettingsPage extends HookConsumerWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
// final downloads = ref.watch(downloadServiceProvider.select(
// (value) => value.downloads,
// ));
return Scaffold(
body: ListView(
children: [
const SizedBox(height: 96),
_SectionHeader(l.settingsServersName),
const _Sources(),
_SectionHeader(l.settingsNetworkName),
const _Network(),
_SectionHeader(l.settingsAboutName),
_About(),
// const _SectionHeader('Downloads'),
// _Section(
// children: downloads
// .map(
// (e) => ListTile(
// isThreeLine: true,
// title: Text(e.filename ?? e.url),
// subtitle: Column(
// mainAxisAlignment: MainAxisAlignment.start,
// children: [
// Row(children: [Text('Progress: ${e.progress}%')]),
// Row(children: [Text('Status: ${e.status})')]),
// Text('Status: ${e.savedDir}'),
// ],
// ),
// trailing:
// CircularProgressIndicator(value: e.progress / 100),
// ),
// )
// .toList(),
// ),
],
),
);
}
}
class _Section extends StatelessWidget {
final List<Widget> children;
const _Section({required this.children});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
...children,
const SizedBox(height: 32),
],
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader(this.title);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
children: [
SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: kHorizontalPadding),
child: Text(
title,
style: theme.textTheme.displaySmall,
),
),
),
const SizedBox(height: 12),
],
);
}
}
class _Network extends StatelessWidget {
const _Network();
@override
Widget build(BuildContext context) {
return const _Section(
children: [
_OfflineMode(),
_MaxBitrateWifi(),
_MaxBitrateMobile(),
_StreamFormat(),
],
);
}
}
class _About extends HookConsumerWidget {
_About();
final _homepage = Uri.parse('https://github.com/austinried/subtracks');
final _donate = Uri.parse('https://ko-fi.com/austinried');
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
final pkg = ref.watch(packageInfoProvider).requireValue;
return _Section(
children: [
ListTile(
title: const Text('subtracks'),
subtitle: Text(l.settingsAboutVersion(pkg.version)),
),
ListTile(
title: Text(l.settingsAboutActionsLicenses),
// trailing: const Icon(Icons.open_in_new_rounded),
onTap: () {},
),
ListTile(
title: Text(l.settingsAboutActionsProjectHomepage),
subtitle: Text(_homepage.toString()),
trailing: const Icon(Icons.open_in_new_rounded),
onTap: () => launchUrl(
_homepage,
mode: LaunchMode.externalApplication,
),
),
ListTile(
title: Text(l.settingsAboutActionsSupport),
subtitle: Text(_donate.toString()),
trailing: const Icon(Icons.open_in_new_rounded),
onTap: () => launchUrl(
_donate,
mode: LaunchMode.externalApplication,
),
),
const SizedBox(height: 12),
const _ShareLogsButton(),
],
);
}
}
class _ShareLogsButton extends StatelessWidget {
const _ShareLogsButton();
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
OutlinedButton.icon(
icon: const Icon(Icons.share),
label: Text(l.settingsAboutShareLogs),
onPressed: () async {
final files = await logFiles();
if (files.isEmpty) return;
// ignore: use_build_context_synchronously
final value = await showDialog<String>(
context: context,
builder: (context) => MultipleChoiceDialog<String>(
title: l.settingsAboutChooseLog,
current: files.first.path,
options: files
.map((e) => MultiChoiceOption.string(
title: p.basename(e.path),
option: e.path,
))
.toIList(),
),
);
if (value == null) return;
Share.shareXFiles(
[XFile(value, mimeType: 'text/plain')],
subject: 'Logs from subtracks: ${String.fromCharCodes(
List.generate(8, (_) => Random().nextInt(26) + 65),
)}',
);
},
),
],
);
}
}
class _MaxBitrateWifi extends HookConsumerWidget {
const _MaxBitrateWifi();
@override
Widget build(BuildContext context, WidgetRef ref) {
final bitrate = ref.watch(settingsServiceProvider.select(
(value) => value.app.maxBitrateWifi,
));
final l = AppLocalizations.of(context);
return _MaxBitrateOption(
title: l.settingsNetworkOptionsMaxBitrateWifiTitle,
bitrate: bitrate,
onChange: (value) {
ref.read(settingsServiceProvider.notifier).setMaxBitrateWifi(value);
},
);
}
}
class _MaxBitrateMobile extends HookConsumerWidget {
const _MaxBitrateMobile();
@override
Widget build(BuildContext context, WidgetRef ref) {
final bitrate = ref.watch(settingsServiceProvider.select(
(value) => value.app.maxBitrateMobile,
));
final l = AppLocalizations.of(context);
return _MaxBitrateOption(
title: l.settingsNetworkOptionsMaxBitrateMobileTitle,
bitrate: bitrate,
onChange: (value) {
ref.read(settingsServiceProvider.notifier).setMaxBitrateMobile(value);
},
);
}
}
class _MaxBitrateOption extends HookConsumerWidget {
final String title;
final int bitrate;
final void Function(int value) onChange;
const _MaxBitrateOption({
required this.title,
required this.bitrate,
required this.onChange,
});
static const options = [0, 24, 32, 64, 96, 128, 192, 256, 320];
String _bitrateText(AppLocalizations l, int bitrate) {
return bitrate == 0
? l.settingsNetworkValuesUnlimitedKbps
: l.settingsNetworkValuesKbps(bitrate.toString());
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final l = AppLocalizations.of(context);
return ListTile(
title: Text(title),
subtitle: Text(_bitrateText(l, bitrate)),
onTap: () async {
final value = await showDialog<int>(
context: context,
builder: (context) => MultipleChoiceDialog<int>(
title: title,
current: bitrate,
options: options
.map((opt) => MultiChoiceOption.int(
title: _bitrateText(l, opt),
option: opt,
))
.toIList(),
),
);
if (value != null) {
onChange(value);
}
},
);
}
}
class _StreamFormat extends HookConsumerWidget {
const _StreamFormat();
static const options = ['', 'mp3', 'opus', 'ogg'];
@override
Widget build(BuildContext context, WidgetRef ref) {
final streamFormat = ref.watch(
settingsServiceProvider.select((value) => value.app.streamFormat),
);
final l = AppLocalizations.of(context);
return ListTile(
title: Text(l.settingsNetworkOptionsStreamFormat),
subtitle: Text(
streamFormat ?? l.settingsNetworkOptionsStreamFormatServerDefault,
),
onTap: () async {
final value = await showDialog<String>(
context: context,
builder: (context) => MultipleChoiceDialog<String>(
title: l.settingsNetworkOptionsStreamFormat,
current: streamFormat ?? '',
options: options
.map((opt) => MultiChoiceOption.string(
title: opt == ''
? l.settingsNetworkOptionsStreamFormatServerDefault
: opt,
option: opt,
))
.toIList(),
),
);
if (value != null) {
ref
.read(settingsServiceProvider.notifier)
.setStreamFormat(value == '' ? null : value);
}
},
);
}
}
class _OfflineMode extends HookConsumerWidget {
const _OfflineMode();
@override
Widget build(BuildContext context, WidgetRef ref) {
final offline = ref.watch(offlineModeProvider);
final l = AppLocalizations.of(context);
return SwitchListTile(
value: offline,
title: Text(l.settingsNetworkOptionsOfflineMode),
subtitle: offline
? Text(l.settingsNetworkOptionsOfflineModeOn)
: Text(l.settingsNetworkOptionsOfflineModeOff),
onChanged: (value) {
ref.read(offlineModeProvider.notifier).setMode(value);
},
);
}
}
class _Sources extends HookConsumerWidget {
const _Sources();
@override
Widget build(BuildContext context, WidgetRef ref) {
final sources = ref.watch(settingsServiceProvider.select(
(value) => value.sources,
));
final activeSource = ref.watch(settingsServiceProvider.select(
(value) => value.activeSource,
));
final l = AppLocalizations.of(context);
return _Section(
children: [
for (var source in sources)
RadioListTile<int>(
value: source.id,
groupValue: activeSource?.id,
onChanged: (value) {
ref
.read(settingsServiceProvider.notifier)
.setActiveSource(source.id);
},
title: Text(source.name),
subtitle: Text(
source.address.toString(),
maxLines: 1,
softWrap: false,
overflow: TextOverflow.fade,
),
secondary: IconButton(
icon: const Icon(Icons.edit_rounded),
onPressed: () {
context.pushRoute(SourceRoute(id: source.id));
},
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
OutlinedButton.icon(
icon: const Icon(Icons.add_rounded),
label: Text(l.settingsServersActionsAdd),
onPressed: () {
context.pushRoute(SourceRoute());
},
),
],
),
// TODO: remove
if (kDebugMode)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
OutlinedButton.icon(
icon: const Icon(Icons.add_rounded),
label: const Text('Add TEST'),
onPressed: () {
ref
.read(settingsServiceProvider.notifier)
.addTestSource('TEST');
},
),
],
),
],
);
}
}

View File

@ -1,511 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sliver_tools/sliver_tools.dart';
import '../../models/music.dart';
import '../../models/query.dart';
import '../../models/support.dart';
import '../../services/audio_service.dart';
import '../../services/cache_service.dart';
import '../../state/music.dart';
import '../../state/settings.dart';
import '../../state/theme.dart';
import '../buttons.dart';
import '../context_menus.dart';
import '../gradient.dart';
import '../hooks/use_download_actions.dart';
import '../hooks/use_list_query_paging_controller.dart';
import '../images.dart';
import '../items.dart';
import '../lists.dart';
class AlbumSongsPage extends HookConsumerWidget {
final String id;
const AlbumSongsPage({
super.key,
@pathParam required this.id,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(albumProvider(id)).valueOrNull;
final audio = ref.watch(audioControlProvider);
final colors = ref.watch(albumArtThemeProvider(id)).valueOrNull;
final key = useState(GlobalKey());
if (album == null) {
return Container();
}
final query = useMemoized(() => const ListQuery(
page: Pagination(limit: 30),
sort: SortBy(column: 'disc, track'),
));
final getSongs = useCallback(
(ListQuery query) => ref.read(albumSongsListProvider(id, query).future),
[id],
);
final play = useCallback(
({int? index, bool? shuffle}) => audio.playSongs(
query: query,
getSongs: getSongs,
startIndex: index,
context: QueueContextType.album,
contextId: id,
shuffle: shuffle,
),
[id, query, getSongs],
);
return QueueContext(
id: id,
type: QueueContextType.album,
child: _SongsPage(
query: query,
getSongs: getSongs,
fab: ShuffleFab(onPressed: () => play(shuffle: true)),
onSongTap: (song, index) => play(index: index),
background: AlbumArtGradient(key: key.value, id: id),
colors: colors,
header: _AlbumHeader(
album: album,
play: () => play(shuffle: false),
),
),
);
}
}
class _AlbumHeader extends HookConsumerWidget {
final Album album;
final void Function() play;
const _AlbumHeader({
required this.album,
required this.play,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cache = ref.watch(cacheServiceProvider);
final downloadActions = useAlbumDownloadActions(
context: context,
ref: ref,
album: album,
);
final l = AppLocalizations.of(context);
return _Header(
title: album.name,
subtitle: album.albumArtist,
imageCache: cache.albumArt(album, thumbnail: false),
playText: l.resourcesAlbumActionsPlay,
onPlay: play,
onMore: () => showContextMenu(
context: context,
ref: ref,
builder: (context) => BottomSheetMenu(
child: AlbumContextMenu(album: album),
),
),
downloadActions: downloadActions,
);
}
}
class PlaylistSongsPage extends HookConsumerWidget {
final String id;
const PlaylistSongsPage({
super.key,
@pathParam required this.id,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final playlist = ref.watch(playlistProvider(id)).valueOrNull;
final audio = ref.watch(audioControlProvider);
final colors = ref.watch(playlistArtThemeProvider(id)).valueOrNull;
if (playlist == null) {
return Container();
}
final query = useMemoized(() => const ListQuery(
page: Pagination(limit: 30),
sort: SortBy(column: 'playlist_songs.position'),
));
final getSongs = useCallback(
(ListQuery query) =>
ref.read(playlistSongsListProvider(id, query).future),
[id],
);
final play = useCallback(
({int? index, bool? shuffle}) => audio.playSongs(
query: query,
getSongs: getSongs,
startIndex: index,
context: QueueContextType.playlist,
contextId: id,
shuffle: shuffle,
),
[id, query, getSongs],
);
return QueueContext(
id: id,
type: QueueContextType.playlist,
child: _SongsPage(
query: query,
getSongs: getSongs,
fab: ShuffleFab(onPressed: () => play(shuffle: true)),
onSongTap: (song, index) => play(index: index),
songImage: true,
background: PlaylistArtGradient(id: id),
colors: colors,
header: _PlaylistHeader(
playlist: playlist,
play: () => play(shuffle: false),
),
),
);
}
}
class _PlaylistHeader extends HookConsumerWidget {
final Playlist playlist;
final void Function() play;
const _PlaylistHeader({
required this.playlist,
required this.play,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cache = ref.watch(cacheServiceProvider);
final downloadActions = usePlaylistDownloadActions(
context: context,
ref: ref,
playlist: playlist,
);
final l = AppLocalizations.of(context);
return _Header(
title: playlist.name,
subtitle: playlist.comment,
imageCache: cache.playlistArt(playlist, thumbnail: false),
playText: l.resourcesPlaylistActionsPlay,
onPlay: play,
onMore: () {
showContextMenu(
context: context,
ref: ref,
builder: (context) => BottomSheetMenu(
size: MenuSize.small,
child: PlaylistContextMenu(playlist: playlist),
),
);
},
downloadActions: downloadActions,
);
}
}
class GenreSongsPage extends HookConsumerWidget {
final String genre;
const GenreSongsPage({
super.key,
@pathParam required this.genre,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final query = useMemoized(
() => ListQuery(
page: const Pagination(limit: 30),
sort: const SortBy(
column: 'albums.created DESC, albums.name, songs.disc, songs.track',
),
filters: IList(
[FilterWith.equals(column: 'songs.genre', value: genre)],
),
),
[genre],
);
final getSongs = useCallback(
(ListQuery query) => ref.read(songsByAlbumListProvider(query).future),
[],
);
final play = useCallback(
({int? index, bool? shuffle}) => ref.read(audioControlProvider).playRadio(
context: QueueContextType.genre,
contextId: genre,
query: query,
getSongs: getSongs,
),
[query, getSongs],
);
return QueueContext(
id: genre,
type: QueueContextType.album,
child: _SongsPage(
query: query,
getSongs: getSongs,
// onSongTap: (song, index) => play(index: index),
songImage: true,
background: const BackgroundGradient(),
fab: RadioPlayFab(
onPressed: () => play(),
),
header: _GenreHeader(genre: genre),
),
);
}
}
class _GenreHeader extends HookConsumerWidget {
final String genre;
const _GenreHeader({
required this.genre,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(songsByGenreCountProvider(genre)).valueOrNull ?? 0;
final l = AppLocalizations.of(context);
return _Header(
title: genre,
subtitle: l.resourcesSongCount(count),
downloadActions: const [],
);
}
}
class QueueContext extends InheritedWidget {
final QueueContextType type;
final String? id;
const QueueContext({
super.key,
required this.type,
this.id,
required super.child,
});
static QueueContext? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<QueueContext>();
}
static QueueContext of(BuildContext context) {
final QueueContext? result = maybeOf(context);
assert(result != null, 'No QueueContext found in context');
return result!;
}
@override
bool updateShouldNotify(covariant QueueContext oldWidget) =>
oldWidget.id != id || oldWidget.type != type;
}
class _SongsPage extends HookConsumerWidget {
final ListQuery query;
final FutureOr<List<Song>> Function(ListQuery query) getSongs;
final void Function(Song song, int index)? onSongTap;
final bool songImage;
final Widget background;
final Widget fab;
final ColorTheme? colors;
final Widget header;
const _SongsPage({
required this.query,
required this.getSongs,
this.onSongTap,
this.songImage = false,
required this.background,
required this.fab,
this.colors,
required this.header,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final base = ref.watch(baseThemeProvider);
ref.listen(musicSourceProvider, (previous, next) {
if (next.id != previous?.id) {
context.router.popUntilRoot();
}
});
final pagingController = useListQueryPagingController(
ref,
query: query,
getItems: getSongs,
);
final widget = Scaffold(
floatingActionButton: fab,
body: CustomScrollView(
slivers: [
SliverStack(
children: [
SliverPositioned.fill(
child: Container(
color: base.gradientLow,
),
),
SliverPositioned.directional(
textDirection: TextDirection.ltr,
start: 0,
end: 0,
top: 0,
child: background,
),
MultiSliver(
children: [
SliverSafeArea(
sliver: SliverToBoxAdapter(
child: Material(
type: MaterialType.transparency,
child: Padding(
padding: const EdgeInsets.all(16),
child: header,
),
),
),
),
PagedListQueryView(
pagingController: pagingController,
useSliver: true,
itemBuilder: (context, item, index) => SongListTile(
song: item,
image: songImage,
onTap: () =>
onSongTap != null ? onSongTap!(item, index) : null,
),
),
],
),
],
),
],
),
);
if (colors != null) {
return Theme(data: colors!.theme, child: widget);
} else {
return widget;
}
}
}
class _Header extends HookConsumerWidget {
final UriCacheInfo? imageCache;
final String title;
final String? subtitle;
final String? playText;
final void Function()? onPlay;
final FutureOr<void> Function()? onMore;
final List<DownloadAction> downloadActions;
const _Header({
this.imageCache,
required this.title,
this.subtitle,
this.playText,
this.onPlay,
this.onMore,
required this.downloadActions,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final inheritedStyle = DefaultTextStyle.of(context).style;
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 10),
if (imageCache != null)
CardClip(
square: false,
child: UriCacheInfoImage(
height: 300,
fit: BoxFit.contain,
placeholderStyle: PlaceholderStyle.spinner,
cache: imageCache!,
),
),
const SizedBox(height: 16),
Column(
children: [
Text(
title,
style: theme.textTheme.titleLarge!.copyWith(
color: inheritedStyle.color,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
Text(
subtitle ?? '',
style: theme.textTheme.titleMedium!.copyWith(
color: inheritedStyle.color,
),
textAlign: TextAlign.center,
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
if (downloadActions.isNotEmpty)
IconButton(
onPressed: downloadActions.first.action,
icon: downloadActions.first.type == DownloadActionType.delete
? const Icon(Icons.download_done_rounded)
: downloadActions.first.iconBuilder(context),
),
if (onPlay != null)
FilledButton.icon(
onPressed: onPlay,
icon: const Icon(Icons.play_arrow_rounded),
label: Text(playText ?? ''),
),
if (onMore != null)
IconButton(
onPressed: onMore,
icon: const Icon(Icons.more_horiz),
)
],
),
],
);
}
}

View File

@ -1,283 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart' show Value;
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../database/database.dart';
import '../../log.dart';
import '../../models/settings.dart';
import '../../services/settings_service.dart';
import '../items.dart';
import '../snackbars.dart';
class SourcePage extends HookConsumerWidget {
final int? id;
const SourcePage({
super.key,
@pathParam this.id,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final source = ref.watch(settingsServiceProvider.select(
(value) => value.sources.singleWhereOrNull((e) => e.id == id)
as SubsonicSettings?,
));
final form = useState(GlobalKey<FormState>()).value;
final theme = Theme.of(context);
final l = AppLocalizations.of(context);
final isSaving = useState(false);
final isDeleting = useState(false);
final name = LabeledTextField(
label: l.settingsServersFieldsName,
initialValue: source?.name,
required: true,
);
final address = LabeledTextField(
label: l.settingsServersFieldsAddress,
initialValue: source?.address.toString(),
keyboardType: TextInputType.url,
autofillHints: const [AutofillHints.url],
required: true,
validator: (value, label) {
if (!value!.contains(RegExp(r'https?:\/\/'))) {
return '$label must be a valid URL';
}
return null;
},
);
final username = LabeledTextField(
label: l.settingsServersFieldsUsername,
initialValue: source?.username,
autofillHints: const [AutofillHints.username],
required: true,
);
final password = LabeledTextField(
label: l.settingsServersFieldsPassword,
initialValue: source?.password,
obscureText: true,
autofillHints: const [AutofillHints.password],
required: true,
);
final forcePlaintextPassword = useState(!(source?.useTokenAuth ?? true));
final forcePlaintextSwitch = SwitchListTile(
value: forcePlaintextPassword.value,
title: Text(l.settingsServersOptionsForcePlaintextPasswordTitle),
subtitle: forcePlaintextPassword.value
? Text(l.settingsServersOptionsForcePlaintextPasswordDescriptionOn)
: Text(l.settingsServersOptionsForcePlaintextPasswordDescriptionOff),
onChanged: (value) => forcePlaintextPassword.value = value,
);
return WillPopScope(
onWillPop: () async => !isSaving.value && !isDeleting.value,
child: Scaffold(
appBar: AppBar(),
floatingActionButton: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (source != null && source.isActive != true)
FloatingActionButton(
backgroundColor: theme.colorScheme.tertiaryContainer,
foregroundColor: theme.colorScheme.onTertiaryContainer,
onPressed: !isSaving.value && !isDeleting.value
? () async {
final router = context.router;
try {
isDeleting.value = true;
await ref
.read(settingsServiceProvider.notifier)
.deleteSource(source.id);
} finally {
isDeleting.value = false;
}
router.pop();
}
: null,
child: isDeleting.value
? SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
color: theme.colorScheme.onTertiaryContainer,
),
)
: const Icon(Icons.delete_forever_rounded),
),
const SizedBox(width: 12),
FloatingActionButton.extended(
heroTag: null,
icon: isSaving.value
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(),
)
: const Icon(Icons.save_rounded),
label: Text(l.settingsServersActionsSave),
onPressed: !isSaving.value && !isDeleting.value
? () async {
final router = context.router;
if (!form.currentState!.validate()) {
return;
}
var error = false;
try {
isSaving.value = true;
if (source != null) {
await ref
.read(settingsServiceProvider.notifier)
.updateSource(
source.copyWith(
name: name.value,
address: Uri.parse(address.value),
username: username.value,
password: password.value,
useTokenAuth: !forcePlaintextPassword.value,
),
);
} else {
await ref
.read(settingsServiceProvider.notifier)
.createSource(
SourcesCompanion.insert(
name: name.value,
address: Uri.parse(address.value),
),
SubsonicSourcesCompanion.insert(
features: IList(),
username: username.value,
password: password.value,
useTokenAuth:
Value(!forcePlaintextPassword.value),
),
);
}
} catch (e, st) {
showErrorSnackbar(context, e.toString());
log.severe('Saving source', e, st);
error = true;
} finally {
isSaving.value = false;
}
if (!error) {
router.pop();
}
}
: null,
),
],
),
body: Form(
key: form,
child: AutofillGroup(
child: ListView(
children: [
const SizedBox(height: 96 - kToolbarHeight),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
source == null
? l.settingsServersActionsAdd
: l.settingsServersActionsEdit,
style: theme.textTheme.displaySmall,
),
),
name,
address,
username,
password,
const SizedBox(height: 24),
forcePlaintextSwitch,
const FabPadding(),
],
),
),
),
),
);
}
}
class LabeledTextField extends HookConsumerWidget {
final String label;
final String? initialValue;
final bool obscureText;
final bool required;
final TextInputType? keyboardType;
final Iterable<String>? autofillHints;
final String? Function(String? value, String label)? validator;
// ignore: prefer_const_constructors_in_immutables
LabeledTextField({
super.key,
required this.label,
this.initialValue,
this.obscureText = false,
this.keyboardType,
this.validator,
this.autofillHints,
this.required = false,
});
late final TextEditingController _controller;
String get value => _controller.text;
String? _requiredValidator(String? value) {
if (value == null || value.isEmpty) {
return '$label is required';
}
return null;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
_controller = useTextEditingController(text: initialValue);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 24),
Text(label, style: theme.textTheme.titleMedium),
TextFormField(
controller: _controller,
obscureText: obscureText,
keyboardType: keyboardType,
autofillHints: autofillHints,
validator: (value) {
String? error;
if (required) {
error = _requiredValidator(value);
if (error != null) {
return error;
}
}
if (validator != null) {
return validator!(value, label);
}
return null;
},
),
],
),
);
}
}

View File

@ -1,14 +0,0 @@
import 'package:flutter/material.dart';
void showErrorSnackbar(BuildContext context, String message) {
final colors = Theme.of(context).colorScheme;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(message, style: TextStyle(color: colors.onErrorContainer)),
backgroundColor: colors.errorContainer,
showCloseIcon: true,
closeIconColor: colors.onErrorContainer,
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 10),
));
}

View File

@ -1,30 +0,0 @@
// ignore_for_file: implementation_imports
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_cache_manager/src/storage/file_system/file_system_io.dart';
import 'package:http/http.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../http/client.dart';
part 'image_cache.g.dart';
CacheManager _openImageCache(BaseClient httpClient) {
const key = 'images';
return CacheManager(
Config(
key,
stalePeriod: const Duration(days: 2147483647),
maxNrOfCacheObjects: 2147483647,
repo: JsonCacheInfoRepository(databaseName: key),
fileSystem: IOFileSystem(key),
fileService: HttpFileService(httpClient: httpClient),
),
);
}
@Riverpod(keepAlive: true)
CacheManager imageCache(ImageCacheRef ref) {
final http = ref.watch(httpClientProvider);
return _openImageCache(http);
}

View File

@ -1,23 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'image_cache.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$imageCacheHash() => r'aaeb74898734c2776f594e05eb82262af20e079f';
/// See also [imageCache].
@ProviderFor(imageCache)
final imageCacheProvider = Provider<CacheManager>.internal(
imageCache,
name: r'imageCacheProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$imageCacheHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef ImageCacheRef = ProviderRef<CacheManager>;
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions

View File

@ -1,72 +0,0 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import '../models/query.dart';
import '../models/settings.dart';
class DurationSecondsConverter extends TypeConverter<Duration, int> {
const DurationSecondsConverter();
@override
Duration fromSql(int fromDb) => Duration(seconds: fromDb);
@override
int toSql(Duration value) => value.inSeconds;
}
class UriConverter extends TypeConverter<Uri, String> {
const UriConverter();
@override
Uri fromSql(String fromDb) => Uri.parse(fromDb);
@override
String toSql(Uri value) => value.toString();
}
class ListQueryConverter extends TypeConverter<ListQuery, String> {
const ListQueryConverter();
@override
ListQuery fromSql(String fromDb) => ListQuery.fromJson(jsonDecode(fromDb));
@override
String toSql(ListQuery value) => jsonEncode(value.toJson());
}
class SubsonicFeatureListConverter
extends TypeConverter<IList<SubsonicFeature>, String> {
const SubsonicFeatureListConverter();
@override
IList<SubsonicFeature> fromSql(String fromDb) {
return IList<SubsonicFeature>.fromJson(
jsonDecode(fromDb),
(item) => SubsonicFeature.values.byName(item as String),
);
}
@override
String toSql(IList<SubsonicFeature> value) {
return jsonEncode(value.toJson((e) => e.toString()));
}
}
class IListIntConverter extends TypeConverter<IList<int>, String> {
const IListIntConverter();
@override
IList<int> fromSql(String fromDb) {
return IList<int>.fromJson(
jsonDecode(fromDb),
(item) => int.parse(item as String),
);
}
@override
String toSql(IList<int> value) {
return jsonEncode(value.toJson((e) => jsonEncode(e)));
}
}

View File

@ -1,681 +0,0 @@
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:drift/isolate.dart';
import 'package:drift/native.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../log.dart';
import '../models/music.dart';
import '../models/query.dart';
import '../models/settings.dart';
import '../models/support.dart';
import 'converters.dart';
import 'error_logging_database.dart';
part 'database.g.dart';
// don't exceed SQLITE_MAX_VARIABLE_NUMBER (32766 for version >= 3.32.0)
// https://www.sqlite.org/limits.html
const kSqliteMaxVariableNumber = 32766;
@DriftDatabase(include: {'tables.drift'})
class SubtracksDatabase extends _$SubtracksDatabase {
SubtracksDatabase() : super(_openConnection());
SubtracksDatabase.connection(QueryExecutor e) : super(e);
@override
int get schemaVersion => 1;
@override
MigrationStrategy get migration {
return MigrationStrategy(
beforeOpen: (details) async {
await customStatement('PRAGMA foreign_keys = ON');
},
);
}
/// Runs a database opertion in a background isolate.
///
/// **Only pass top-level functions to [computation]!**
///
/// **Do not use non-serializable data inside [computation]!**
Future<Ret> background<Ret>(
FutureOr<Ret> Function(SubtracksDatabase) computation,
) async {
return computeWithDatabase(
connect: SubtracksDatabase.connection,
computation: computation,
);
}
MultiSelectable<Album> albumsList(int sourceId, ListQuery opt) {
return filterAlbums(
(_) => _filterPredicate('albums', sourceId, opt),
(_) => _filterOrderBy(opt),
(_) => _filterLimit(opt),
);
}
MultiSelectable<Album> albumsListDownloaded(int sourceId, ListQuery opt) {
return filterAlbumsDownloaded(
(_, __) => _filterPredicate('albums', sourceId, opt),
(_, __) => _filterOrderBy(opt),
(_, __) => _filterLimit(opt),
);
}
MultiSelectable<Artist> artistsList(int sourceId, ListQuery opt) {
return filterArtists(
(_) => _filterPredicate('artists', sourceId, opt),
(_) => _filterOrderBy(opt),
(_) => _filterLimit(opt),
);
}
MultiSelectable<Artist> artistsListDownloaded(int sourceId, ListQuery opt) {
return filterArtistsDownloaded(
(_, __, ___) => _filterPredicate('artists', sourceId, opt),
(_, __, ___) => _filterOrderBy(opt),
(_, __, ___) => _filterLimit(opt),
);
}
MultiSelectable<Playlist> playlistsList(int sourceId, ListQuery opt) {
return filterPlaylists(
(_) => _filterPredicate('playlists', sourceId, opt),
(_) => _filterOrderBy(opt),
(_) => _filterLimit(opt),
);
}
MultiSelectable<Playlist> playlistsListDownloaded(
int sourceId, ListQuery opt) {
return filterPlaylistsDownloaded(
(_, __, ___) => _filterPredicate('playlists', sourceId, opt),
(_, __, ___) => _filterOrderBy(opt),
(_, __, ___) => _filterLimit(opt),
);
}
MultiSelectable<Song> songsList(int sourceId, ListQuery opt) {
return filterSongs(
(_) => _filterPredicate('songs', sourceId, opt),
(_) => _filterOrderBy(opt),
(_) => _filterLimit(opt),
);
}
MultiSelectable<Song> songsListDownloaded(int sourceId, ListQuery opt) {
return filterSongsDownloaded(
(_) => _filterPredicate('songs', sourceId, opt),
(_) => _filterOrderBy(opt),
(_) => _filterLimit(opt),
);
}
Expression<bool> _filterPredicate(String table, int sourceId, ListQuery opt) {
return opt.filters.map((filter) => buildFilter<bool>(filter)).fold(
CustomExpression('$table.source_id = $sourceId'),
(previousValue, element) => previousValue & element,
);
}
OrderBy _filterOrderBy(ListQuery opt) {
return opt.sort != null
? OrderBy([_buildOrder(opt.sort!)])
: const OrderBy.nothing();
}
Limit _filterLimit(ListQuery opt) {
return Limit(opt.page.limit, opt.page.offset);
}
MultiSelectable<Song> albumSongsList(SourceId sid, ListQuery opt) {
return listQuery(
select(songs)
..where((tbl) =>
tbl.sourceId.equals(sid.sourceId) & tbl.albumId.equals(sid.id)),
opt,
);
}
MultiSelectable<Song> songsByAlbumList(int sourceId, ListQuery opt) {
return filterSongsByGenre(
(_, __) => _filterPredicate('songs', sourceId, opt),
(_, __) => _filterOrderBy(opt),
(_, __) => _filterLimit(opt),
);
}
MultiSelectable<Song> playlistSongsList(SourceId sid, ListQuery opt) {
return listQueryJoined(
select(songs).join([
innerJoin(
playlistSongs,
playlistSongs.sourceId.equalsExp(songs.sourceId) &
playlistSongs.songId.equalsExp(songs.id),
useColumns: false,
),
])
..where(playlistSongs.sourceId.equals(sid.sourceId) &
playlistSongs.playlistId.equals(sid.id)),
opt,
).map((row) => row.readTable(songs));
}
Future<void> saveArtists(Iterable<ArtistsCompanion> artists) async {
await batch((batch) {
batch.insertAllOnConflictUpdate(this.artists, artists);
});
}
Future<void> deleteArtistsNotIn(int sourceId, Set<String> ids) {
return transaction(() async {
final allIds = (await (selectOnly(artists)
..addColumns([artists.id])
..where(artists.sourceId.equals(sourceId)))
.map((row) => row.read(artists.id))
.get())
.whereNotNull()
.toSet();
final downloadIds = (await artistIdsWithDownloadStatus(sourceId).get())
.whereNotNull()
.toSet();
final diff = allIds.difference(downloadIds).difference(ids);
for (var slice in diff.slices(kSqliteMaxVariableNumber)) {
await (delete(artists)
..where(
(tbl) => tbl.sourceId.equals(sourceId) & tbl.id.isIn(slice)))
.go();
}
});
}
Future<void> saveAlbums(Iterable<AlbumsCompanion> albums) async {
await batch((batch) {
batch.insertAllOnConflictUpdate(this.albums, albums);
});
}
Future<void> deleteAlbumsNotIn(int sourceId, Set<String> ids) {
return transaction(() async {
final allIds = (await (selectOnly(albums)
..addColumns([albums.id])
..where(albums.sourceId.equals(sourceId)))
.map((row) => row.read(albums.id))
.get())
.whereNotNull()
.toSet();
final downloadIds = (await albumIdsWithDownloadStatus(sourceId).get())
.whereNotNull()
.toSet();
final diff = allIds.difference(downloadIds).difference(ids);
for (var slice in diff.slices(kSqliteMaxVariableNumber)) {
await (delete(albums)
..where(
(tbl) => tbl.sourceId.equals(sourceId) & tbl.id.isIn(slice)))
.go();
}
});
}
Future<void> savePlaylists(
Iterable<PlaylistWithSongsCompanion> playlistsWithSongs,
) async {
final playlists = playlistsWithSongs.map((e) => e.playist);
final playlistSongs = playlistsWithSongs.expand((e) => e.songs);
final sourceId = playlists.first.sourceId.value;
await (delete(this.playlistSongs)
..where(
(tbl) =>
tbl.sourceId.equals(sourceId) &
tbl.playlistId.isIn(playlists.map((e) => e.id.value)),
))
.go();
await batch((batch) {
batch.insertAllOnConflictUpdate(this.playlists, playlists);
batch.insertAllOnConflictUpdate(this.playlistSongs, playlistSongs);
});
}
Future<void> deletePlaylistsNotIn(int sourceId, Set<String> ids) {
return transaction(() async {
final allIds = (await (selectOnly(playlists)
..addColumns([playlists.id])
..where(playlists.sourceId.equals(sourceId)))
.map((row) => row.read(playlists.id))
.get())
.whereNotNull()
.toSet();
final downloadIds = (await playlistIdsWithDownloadStatus(sourceId).get())
.whereNotNull()
.toSet();
final diff = allIds.difference(downloadIds).difference(ids);
for (var slice in diff.slices(kSqliteMaxVariableNumber)) {
await (delete(playlists)
..where(
(tbl) => tbl.sourceId.equals(sourceId) & tbl.id.isIn(slice)))
.go();
await (delete(playlistSongs)
..where((tbl) =>
tbl.sourceId.equals(sourceId) & tbl.playlistId.isIn(slice)))
.go();
}
});
}
Future<void> savePlaylistSongs(
int sourceId,
List<String> ids,
Iterable<PlaylistSongsCompanion> playlistSongs,
) async {
await (delete(this.playlistSongs)
..where(
(tbl) => tbl.sourceId.equals(sourceId) & tbl.playlistId.isIn(ids),
))
.go();
await batch((batch) {
batch.insertAllOnConflictUpdate(this.playlistSongs, playlistSongs);
});
}
Future<void> saveSongs(Iterable<SongsCompanion> songs) async {
await batch((batch) {
batch.insertAllOnConflictUpdate(this.songs, songs);
});
}
Future<void> deleteSongsNotIn(int sourceId, Set<String> ids) {
return transaction(() async {
final allIds = (await (selectOnly(songs)
..addColumns([songs.id])
..where(
songs.sourceId.equals(sourceId) &
songs.downloadFilePath.isNull() &
songs.downloadTaskId.isNull(),
))
.map((row) => row.read(songs.id))
.get())
.whereNotNull()
.toSet();
final diff = allIds.difference(ids);
for (var slice in diff.slices(kSqliteMaxVariableNumber)) {
await (delete(songs)
..where(
(tbl) => tbl.sourceId.equals(sourceId) & tbl.id.isIn(slice)))
.go();
await (delete(playlistSongs)
..where(
(tbl) => tbl.sourceId.equals(sourceId) & tbl.songId.isIn(slice),
))
.go();
}
});
}
Selectable<LastBottomNavStateData> getLastBottomNavState() {
return select(lastBottomNavState)..where((tbl) => tbl.id.equals(1));
}
Future<void> saveLastBottomNavState(LastBottomNavStateData update) {
return into(lastBottomNavState).insertOnConflictUpdate(update);
}
Selectable<LastLibraryStateData> getLastLibraryState() {
return select(lastLibraryState)..where((tbl) => tbl.id.equals(1));
}
Future<void> saveLastLibraryState(LastLibraryStateData update) {
return into(lastLibraryState).insertOnConflictUpdate(update);
}
Selectable<LastAudioStateData> getLastAudioState() {
return select(lastAudioState)..where((tbl) => tbl.id.equals(1));
}
Future<void> saveLastAudioState(LastAudioStateCompanion update) {
return into(lastAudioState).insertOnConflictUpdate(update);
}
Future<void> insertQueue(Iterable<QueueCompanion> songs) async {
await batch((batch) {
batch.insertAll(queue, songs);
});
}
Future<void> clearQueue() async {
await delete(queue).go();
}
Future<void> setCurrentTrack(int index) async {
await transaction(() async {
await (update(queue)..where((tbl) => tbl.index.equals(index).not()))
.write(const QueueCompanion(currentTrack: Value(null)));
await (update(queue)..where((tbl) => tbl.index.equals(index)))
.write(const QueueCompanion(currentTrack: Value(true)));
});
}
Future<void> createSource(
SourcesCompanion source,
SubsonicSourcesCompanion subsonic,
) async {
await transaction(() async {
final count = await sourcesCount().getSingle();
if (count == 0) {
source = source.copyWith(isActive: const Value(true));
}
final id = await into(sources).insert(source);
subsonic = subsonic.copyWith(sourceId: Value(id));
await into(subsonicSources).insert(subsonic);
});
}
Future<void> updateSource(SubsonicSettings source) async {
await transaction(() async {
await into(sources).insertOnConflictUpdate(source.toSourceInsertable());
await into(subsonicSources)
.insertOnConflictUpdate(source.toSubsonicInsertable());
});
}
Future<void> deleteSource(int sourceId) async {
await transaction(() async {
await (delete(subsonicSources)
..where((tbl) => tbl.sourceId.equals(sourceId)))
.go();
await (delete(sources)..where((tbl) => tbl.id.equals(sourceId))).go();
await (delete(songs)..where((tbl) => tbl.sourceId.equals(sourceId))).go();
await (delete(albums)..where((tbl) => tbl.sourceId.equals(sourceId)))
.go();
await (delete(artists)..where((tbl) => tbl.sourceId.equals(sourceId)))
.go();
await (delete(playlistSongs)
..where((tbl) => tbl.sourceId.equals(sourceId)))
.go();
await (delete(playlists)..where((tbl) => tbl.sourceId.equals(sourceId)))
.go();
});
}
Future<void> setActiveSource(int id) async {
await batch((batch) {
batch.update(
sources,
const SourcesCompanion(isActive: Value(null)),
where: (t) => t.id.isNotValue(id),
);
batch.update(
sources,
const SourcesCompanion(isActive: Value(true)),
where: (t) => t.id.equals(id),
);
});
}
Future<void> updateSettings(AppSettingsCompanion settings) async {
await into(appSettings).insertOnConflictUpdate(settings);
}
}
LazyDatabase _openConnection() {
return LazyDatabase(() async {
final dbFolder = await getApplicationDocumentsDirectory();
final file = File(p.join(dbFolder.path, 'subtracks.sqlite'));
// return NativeDatabase.createInBackground(file, logStatements: true);
return ErrorLoggingDatabase(
NativeDatabase.createInBackground(file),
(e, s) => log.severe('SQL error', e, s),
);
});
}
@Riverpod(keepAlive: true)
SubtracksDatabase database(DatabaseRef ref) {
return SubtracksDatabase();
}
OrderingTerm _buildOrder(SortBy sort) {
OrderingMode? mode =
sort.dir == SortDirection.asc ? OrderingMode.asc : OrderingMode.desc;
return OrderingTerm(
expression: CustomExpression(sort.column),
mode: mode,
);
}
SimpleSelectStatement<T, R> listQuery<T extends HasResultSet, R>(
SimpleSelectStatement<T, R> query,
ListQuery opt,
) {
if (opt.page.limit > 0) {
query.limit(opt.page.limit, offset: opt.page.offset);
}
if (opt.sort != null) {
OrderingMode? mode = opt.sort != null && opt.sort!.dir == SortDirection.asc
? OrderingMode.asc
: OrderingMode.desc;
query.orderBy([
(t) => OrderingTerm(
expression: CustomExpression(opt.sort!.column),
mode: mode,
)
]);
}
for (var filter in opt.filters) {
query.where((tbl) => buildFilter(filter));
}
return query;
}
JoinedSelectStatement<T, R> listQueryJoined<T extends HasResultSet, R>(
JoinedSelectStatement<T, R> query,
ListQuery opt,
) {
if (opt.page.limit > 0) {
query.limit(opt.page.limit, offset: opt.page.offset);
}
if (opt.sort != null) {
OrderingMode? mode = opt.sort != null && opt.sort!.dir == SortDirection.asc
? OrderingMode.asc
: OrderingMode.desc;
query.orderBy([
OrderingTerm(
expression: CustomExpression(opt.sort!.column),
mode: mode,
)
]);
}
for (var filter in opt.filters) {
query.where(buildFilter(filter));
}
return query;
}
CustomExpression<T> buildFilter<T extends Object>(
FilterWith filter,
) {
return filter.when(
equals: (column, value, invert) => CustomExpression<T>(
'$column ${invert ? '<>' : '='} \'$value\'',
),
greaterThan: (column, value, orEquals) => CustomExpression<T>(
'$column ${orEquals ? '>=' : '>'} $value',
),
isNull: (column, invert) => CustomExpression<T>(
'$column ${invert ? 'IS NOT' : 'IS'} NULL',
),
betweenInt: (column, from, to) => CustomExpression<T>(
'$column BETWEEN $from AND $to',
),
isIn: (column, invert, values) => CustomExpression<T>(
'$column ${invert ? 'NOT IN' : 'IN'} (${values.join(',')})',
),
);
}
class AlbumSongsCompanion {
final AlbumsCompanion album;
final Iterable<SongsCompanion> songs;
AlbumSongsCompanion(this.album, this.songs);
}
class ArtistAlbumsCompanion {
final ArtistsCompanion artist;
final Iterable<AlbumsCompanion> albums;
ArtistAlbumsCompanion(this.artist, this.albums);
}
class PlaylistWithSongsCompanion {
final PlaylistsCompanion playist;
final Iterable<PlaylistSongsCompanion> songs;
PlaylistWithSongsCompanion(this.playist, this.songs);
}
// Future<void> saveArtist(
// SubtracksDatabase db,
// ArtistAlbumsCompanion artistAlbums,
// ) async {
// return db.background((db) async {
// final artist = artistAlbums.artist;
// final albums = artistAlbums.albums;
// await db.batch((batch) {
// batch.insertAllOnConflictUpdate(db.artists, [artist]);
// batch.insertAllOnConflictUpdate(db.albums, albums);
// // remove this artistId from albums not found in source
// // don't delete them since they coud have been moved to another artist
// // that we haven't synced yet
// final albumIds = {for (var a in albums) a.id.value};
// batch.update(
// db.albums,
// const AlbumsCompanion(artistId: Value(null)),
// where: (tbl) =>
// tbl.sourceId.equals(artist.sourceId.value) &
// tbl.artistId.equals(artist.id.value) &
// tbl.id.isNotIn(albumIds),
// );
// });
// });
// }
// Future<void> saveAlbum(
// SubtracksDatabase db,
// AlbumSongsCompanion albumSongs,
// ) async {
// return db.background((db) async {
// final album = albumSongs.album.copyWith(synced: Value(DateTime.now()));
// final songs = albumSongs.songs;
// final songIds = {for (var a in songs) a.id.value};
// final hardDeletedSongIds = (await (db.selectOnly(db.songs)
// ..addColumns([db.songs.id])
// ..where(
// db.songs.sourceId.equals(album.sourceId.value) &
// db.songs.albumId.equals(album.id.value) &
// db.songs.id.isNotIn(songIds) &
// db.songs.downloadFilePath.isNull() &
// db.songs.downloadTaskId.isNull(),
// ))
// .map((row) => row.read(db.songs.id))
// .get())
// .whereNotNull();
// await db.batch((batch) {
// batch.insertAllOnConflictUpdate(db.albums, [album]);
// batch.insertAllOnConflictUpdate(db.songs, songs);
// // soft delete songs that have been downloaded so that the user
// // can decide to keep or remove them later
// // TODO: add a setting to skip soft delete and just remove download too
// batch.update(
// db.songs,
// const SongsCompanion(isDeleted: Value(true)),
// where: (tbl) =>
// tbl.sourceId.equals(album.sourceId.value) &
// tbl.albumId.equals(album.id.value) &
// tbl.id.isNotIn(songIds) &
// (tbl.downloadFilePath.isNotNull() | tbl.downloadTaskId.isNotNull()),
// );
// // safe to hard delete songs that have not been downloaded
// batch.deleteWhere(
// db.songs,
// (tbl) =>
// tbl.sourceId.equals(album.sourceId.value) &
// tbl.id.isIn(hardDeletedSongIds),
// );
// // also need to remove these songs from any playlists that contain them
// batch.deleteWhere(
// db.playlistSongs,
// (tbl) =>
// tbl.sourceId.equals(album.sourceId.value) &
// tbl.songId.isIn(hardDeletedSongIds),
// );
// });
// });
// }
// Future<void> savePlaylist(
// SubtracksDatabase db,
// PlaylistWithSongsCompanion playlistWithSongs,
// ) async {
// return db.background((db) async {
// final playlist =
// playlistWithSongs.playist.copyWith(synced: Value(DateTime.now()));
// final songs = playlistWithSongs.songs;
// await db.batch((batch) {
// batch.insertAllOnConflictUpdate(db.playlists, [playlist]);
// batch.insertAllOnConflictUpdate(db.songs, songs);
// batch.insertAllOnConflictUpdate(
// db.playlistSongs,
// songs.mapIndexed(
// (index, song) => PlaylistSongsCompanion.insert(
// sourceId: playlist.sourceId.value,
// playlistId: playlist.id.value,
// songId: song.id.value,
// position: index,
// ),
// ),
// );
// // the new playlist could be shorter than the old one, so we delete
// // playlist songs above our new playlist's length
// batch.deleteWhere(
// db.playlistSongs,
// (tbl) =>
// tbl.sourceId.equals(playlist.sourceId.value) &
// tbl.playlistId.equals(playlist.id.value) &
// tbl.position.isBiggerOrEqualValue(songs.length),
// );
// });
// });
// }

File diff suppressed because it is too large Load Diff

View File

@ -1,94 +0,0 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:drift/isolate.dart';
/// https://github.com/simolus3/drift/issues/2326#issuecomment-1445138730
class ErrorLoggingDatabase implements QueryExecutor {
final QueryExecutor inner;
final void Function(Object, StackTrace) onError;
ErrorLoggingDatabase(this.inner, this.onError);
Future<T> _handleErrors<T>(Future<T> Function() body) {
return Future.sync(body)
.onError<DriftWrappedException>((error, stackTrace) {
onError(error, error.trace ?? stackTrace);
throw error;
}).onError<DriftRemoteException>((error, stackTrace) {
onError(error, error.remoteStackTrace ?? stackTrace);
throw error;
});
}
@override
TransactionExecutor beginTransaction() {
return _ErrorLoggingTransactionExecutor(inner.beginTransaction(), onError);
}
@override
Future<void> close() {
return _handleErrors(inner.close);
}
@override
SqlDialect get dialect => inner.dialect;
@override
Future<bool> ensureOpen(QueryExecutorUser user) {
return _handleErrors(() => inner.ensureOpen(user));
}
@override
Future<void> runBatched(BatchedStatements statements) {
return _handleErrors(() => inner.runBatched(statements));
}
@override
Future<void> runCustom(String statement, [List<Object?>? args]) {
return _handleErrors(() => inner.runCustom(statement, args));
}
@override
Future<int> runDelete(String statement, List<Object?> args) {
return _handleErrors(() => inner.runDelete(statement, args));
}
@override
Future<int> runInsert(String statement, List<Object?> args) {
return _handleErrors(() => inner.runInsert(statement, args));
}
@override
Future<List<Map<String, Object?>>> runSelect(
String statement, List<Object?> args) {
return _handleErrors(() => inner.runSelect(statement, args));
}
@override
Future<int> runUpdate(String statement, List<Object?> args) {
return _handleErrors(() => inner.runUpdate(statement, args));
}
}
class _ErrorLoggingTransactionExecutor extends ErrorLoggingDatabase
implements TransactionExecutor {
final TransactionExecutor transaction;
_ErrorLoggingTransactionExecutor(
this.transaction, void Function(Object, StackTrace) onError)
: super(transaction, onError);
@override
Future<void> rollback() {
return _handleErrors(transaction.rollback);
}
@override
Future<void> send() {
return _handleErrors(transaction.send);
}
@override
bool get supportsNestedTransactions => transaction.supportsNestedTransactions;
}

View File

@ -1,567 +0,0 @@
import '../models/music.dart';
import '../models/settings.dart';
import '../models/support.dart';
import 'converters.dart';
--
-- SCHEMA
--
CREATE TABLE queue(
"index" INT NOT NULL PRIMARY KEY UNIQUE,
source_id INT NOT NULL,
id TEXT NOT NULL,
context ENUM(QueueContextType) NOT NULL,
context_id TEXT,
current_track BOOLEAN UNIQUE
);
CREATE INDEX queue_index ON queue ("index");
CREATE INDEX queue_current_track ON queue ("current_track");
CREATE TABLE last_audio_state(
id INT NOT NULL PRIMARY KEY,
queue_mode ENUM(QueueMode) NOT NULL,
shuffle_indicies TEXT MAPPED BY `const IListIntConverter()`,
repeat ENUM(RepeatMode) NOT NULL
);
CREATE TABLE last_bottom_nav_state(
id INT NOT NULL PRIMARY KEY,
tab TEXT NOT NULL
);
CREATE TABLE last_library_state(
id INT NOT NULL PRIMARY KEY,
tab TEXT NOT NULL,
albums_list TEXT NOT NULL MAPPED BY `const ListQueryConverter()`,
artists_list TEXT NOT NULL MAPPED BY `const ListQueryConverter()`,
playlists_list TEXT NOT NULL MAPPED BY `const ListQueryConverter()`,
songs_list TEXT NOT NULL MAPPED BY `const ListQueryConverter()`
);
CREATE TABLE app_settings(
id INT NOT NULL PRIMARY KEY,
max_bitrate_wifi INT NOT NULL,
max_bitrate_mobile INT NOT NULL,
stream_format TEXT
) WITH AppSettings;
CREATE TABLE sources(
id INT NOT NULL PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL COLLATE NOCASE,
address TEXT NOT NULL MAPPED BY `const UriConverter()`,
is_active BOOLEAN UNIQUE,
created_at DATETIME NOT NULL DEFAULT (strftime('%s', CURRENT_TIMESTAMP))
);
CREATE TABLE subsonic_sources(
source_id INT NOT NULL PRIMARY KEY,
features TEXT NOT NULL MAPPED BY `const SubsonicFeatureListConverter()`,
username TEXT NOT NULL,
password TEXT NOT NULL,
use_token_auth BOOLEAN NOT NULL DEFAULT 1,
FOREIGN KEY (source_id) REFERENCES sources (id) ON DELETE CASCADE
);
CREATE TABLE artists(
source_id INT NOT NULL,
id TEXT NOT NULL,
name TEXT NOT NULL COLLATE NOCASE,
album_count INT NOT NULL,
starred DATETIME,
updated DATETIME NOT NULL DEFAULT (strftime('%s', CURRENT_TIMESTAMP)),
PRIMARY KEY (source_id, id),
FOREIGN KEY (source_id) REFERENCES sources (id) ON DELETE CASCADE
) WITH Artist;
CREATE INDEX artists_source_id ON artists (source_id);
CREATE VIRTUAL TABLE artists_fts USING fts5(source_id, name, content=artists, content_rowid=rowid);
CREATE TRIGGER artists_ai AFTER INSERT ON artists BEGIN
INSERT INTO artists_fts(rowid, source_id, name)
VALUES (new.rowid, new.source_id, new.name);
END;
CREATE TRIGGER artists_ad AFTER DELETE ON artists BEGIN
INSERT INTO artists_fts(artists_fts, rowid, source_id, name)
VALUES('delete', old.rowid, old.source_id, old.name);
END;
CREATE TRIGGER artists_au AFTER UPDATE ON artists BEGIN
INSERT INTO artists_fts(artists_fts, rowid, source_id, name)
VALUES('delete', old.rowid, old.source_id, old.name);
INSERT INTO artists_fts(rowid, source_id, name)
VALUES (new.rowid, new.source_id, new.name);
END;
CREATE TABLE albums(
source_id INT NOT NULL,
id TEXT NOT NULL,
artist_id TEXT,
name TEXT NOT NULL COLLATE NOCASE,
album_artist TEXT COLLATE NOCASE,
created DATETIME NOT NULL,
cover_art TEXT,
genre TEXT,
year INT,
starred DATETIME,
song_count INT NOT NULL,
frequent_rank INT,
recent_rank INT,
is_deleted BOOLEAN NOT NULL DEFAULT 0,
updated DATETIME NOT NULL DEFAULT (strftime('%s', CURRENT_TIMESTAMP)),
PRIMARY KEY (source_id, id),
FOREIGN KEY (source_id) REFERENCES sources (id) ON DELETE CASCADE
) WITH Album;
CREATE INDEX albums_source_id ON albums (source_id);
CREATE INDEX albums_source_id_artist_id_idx ON albums (source_id, artist_id);
CREATE VIRTUAL TABLE albums_fts USING fts5(source_id, name, content=albums, content_rowid=rowid);
CREATE TRIGGER albums_ai AFTER INSERT ON albums BEGIN
INSERT INTO albums_fts(rowid, source_id, name)
VALUES (new.rowid, new.source_id, new.name);
END;
CREATE TRIGGER albums_ad AFTER DELETE ON albums BEGIN
INSERT INTO albums_fts(albums_fts, rowid, source_id, name)
VALUES('delete', old.rowid, old.source_id, old.name);
END;
CREATE TRIGGER albums_au AFTER UPDATE ON albums BEGIN
INSERT INTO albums_fts(albums_fts, rowid, source_id, name)
VALUES('delete', old.rowid, old.source_id, old.name);
INSERT INTO albums_fts(rowid, source_id, name)
VALUES (new.rowid, new.source_id, new.name);
END;
CREATE TABLE playlists(
source_id INT NOT NULL,
id TEXT NOT NULL,
name TEXT NOT NULL COLLATE NOCASE,
comment TEXT COLLATE NOCASE,
cover_art TEXT,
song_count INT NOT NULL,
created DATETIME NOT NULL,
updated DATETIME NOT NULL DEFAULT (strftime('%s', CURRENT_TIMESTAMP)),
PRIMARY KEY (source_id, id),
FOREIGN KEY (source_id) REFERENCES sources (id) ON DELETE CASCADE
) WITH Playlist;
CREATE INDEX playlists_source_id ON playlists (source_id);
CREATE TABLE playlist_songs(
source_id INT NOT NULL,
playlist_id TEXT NOT NULL,
song_id TEXT NOT NULL,
position INT NOT NULL,
updated DATETIME NOT NULL DEFAULT (strftime('%s', CURRENT_TIMESTAMP)),
PRIMARY KEY (source_id, playlist_id, position),
FOREIGN KEY (source_id) REFERENCES sources (id) ON DELETE CASCADE
);
CREATE INDEX playlist_songs_source_id_playlist_id_idx ON playlist_songs (source_id, playlist_id);
CREATE INDEX playlist_songs_source_id_song_id_idx ON playlist_songs (source_id, song_id);
CREATE VIRTUAL TABLE playlists_fts USING fts5(source_id, name, content=playlists, content_rowid=rowid);
CREATE TRIGGER playlists_ai AFTER INSERT ON playlists BEGIN
INSERT INTO playlists_fts(rowid, source_id, name)
VALUES (new.rowid, new.source_id, new.name);
END;
CREATE TRIGGER playlists_ad AFTER DELETE ON playlists BEGIN
INSERT INTO playlists_fts(playlists_fts, rowid, source_id, name)
VALUES('delete', old.rowid, old.source_id, old.name);
END;
CREATE TRIGGER playlists_au AFTER UPDATE ON playlists BEGIN
INSERT INTO playlists_fts(playlists_fts, rowid, source_id, name)
VALUES('delete', old.rowid, old.source_id, old.name);
INSERT INTO playlists_fts(rowid, source_id, name)
VALUES (new.rowid, new.source_id, new.name);
END;
CREATE TABLE songs(
source_id INT NOT NULL,
id TEXT NOT NULL,
album_id TEXT,
artist_id TEXT,
title TEXT NOT NULL COLLATE NOCASE,
album TEXT COLLATE NOCASE,
artist TEXT COLLATE NOCASE,
duration INT MAPPED BY `const DurationSecondsConverter()`,
track INT,
disc INT,
starred DATETIME,
genre TEXT,
download_task_id TEXT UNIQUE,
download_file_path TEXT UNIQUE,
is_deleted BOOLEAN NOT NULL DEFAULT 0,
updated DATETIME NOT NULL DEFAULT (strftime('%s', CURRENT_TIMESTAMP)),
PRIMARY KEY (source_id, id),
FOREIGN KEY (source_id) REFERENCES sources (id) ON DELETE CASCADE
) WITH Song;
CREATE INDEX songs_source_id_album_id_idx ON songs (source_id, album_id);
CREATE INDEX songs_source_id_artist_id_idx ON songs (source_id, artist_id);
CREATE INDEX songs_download_task_id_idx ON songs (download_task_id);
CREATE VIRTUAL TABLE songs_fts USING fts5(source_id, title, content=songs, content_rowid=rowid);
CREATE TRIGGER songs_ai AFTER INSERT ON songs BEGIN
INSERT INTO songs_fts(rowid, source_id, title)
VALUES (new.rowid, new.source_id, new.title);
END;
CREATE TRIGGER songs_ad AFTER DELETE ON songs BEGIN
INSERT INTO songs_fts(songs_fts, rowid, source_id, title)
VALUES('delete', old.rowid, old.source_id, old.title);
END;
CREATE TRIGGER songs_au AFTER UPDATE ON songs BEGIN
INSERT INTO songs_fts(songs_fts, rowid, source_id, title)
VALUES('delete', old.rowid, old.source_id, old.title);
INSERT INTO songs_fts(rowid, source_id, title)
VALUES (new.rowid, new.source_id, new.title);
END;
--
-- QUERIES
--
sourcesCount:
SELECT COUNT(*)
FROM sources;
allSubsonicSources WITH SubsonicSettings:
SELECT
sources.id,
sources.name,
sources.address,
sources.is_active,
sources.created_at,
subsonic_sources.features,
subsonic_sources.username,
subsonic_sources.password,
subsonic_sources.use_token_auth
FROM sources
JOIN subsonic_sources ON subsonic_sources.source_id = sources.id;
albumIdsWithDownloadStatus:
SELECT albums.id
FROM albums
JOIN songs on songs.source_id = albums.source_id AND songs.album_id = albums.id
WHERE
albums.source_id = :source_id
AND (songs.download_file_path IS NOT NULL OR songs.download_task_id IS NOT NULL)
GROUP BY albums.id;
artistIdsWithDownloadStatus:
SELECT artists.id
FROM artists
LEFT JOIN albums ON artists.source_id = albums.source_id AND artists.id = albums.artist_id
LEFT JOIN songs ON albums.source_id = songs.source_id AND albums.id = songs.album_id
WHERE
artists.source_id = :source_id
AND (songs.download_file_path IS NOT NULL OR songs.download_task_id IS NOT NULL)
GROUP BY artists.id;
playlistIdsWithDownloadStatus:
SELECT playlists.id
FROM playlists
LEFT JOIN playlist_songs ON playlist_songs.source_id = playlists.source_id AND playlist_songs.playlist_id = playlists.id
LEFT JOIN songs ON playlist_songs.source_id = songs.source_id AND playlist_songs.song_id = songs.id
WHERE
playlists.source_id = :source_id
AND (songs.download_file_path IS NOT NULL OR songs.download_task_id IS NOT NULL)
GROUP BY playlists.id;
searchArtists:
SELECT rowid
FROM artists_fts
WHERE artists_fts MATCH :query
ORDER BY rank
LIMIT :limit OFFSET :offset;
searchAlbums:
SELECT rowid
FROM albums_fts
WHERE albums_fts MATCH :query
ORDER BY rank
LIMIT :limit OFFSET :offset;
searchPlaylists:
SELECT rowid
FROM playlists_fts
WHERE playlists_fts MATCH :query
ORDER BY rank
LIMIT :limit OFFSET :offset;
searchSongs:
SELECT rowid
FROM songs_fts
WHERE songs_fts MATCH :query
ORDER BY rank
LIMIT :limit OFFSET :offset;
artistById:
SELECT * FROM artists
WHERE source_id = :source_id AND id = :id;
albumById:
SELECT * FROM albums
WHERE source_id = :source_id AND id = :id;
albumsByArtistId:
SELECT * FROM albums
WHERE source_id = :source_id AND artist_id = :artist_id;
albumsInIds:
SELECT * FROM albums
WHERE source_id = :source_id AND id IN :ids;
playlistById:
SELECT * FROM playlists
WHERE source_id = :source_id AND id = :id;
songById:
SELECT * FROM songs
WHERE source_id = :source_id AND id = :id;
albumGenres:
SELECT
genre
FROM albums
WHERE genre IS NOT NULL AND source_id = :source_id
GROUP BY genre
ORDER BY COUNT(genre) DESC
LIMIT :limit OFFSET :offset;
albumsByGenre:
SELECT
albums.*
FROM albums
JOIN songs ON albums.source_id = songs.source_id AND albums.id = songs.album_id
WHERE songs.source_id = :source_id AND songs.genre = :genre
GROUP BY albums.id
ORDER BY albums.created DESC, albums.name
LIMIT :limit OFFSET :offset;
filterSongsByGenre:
SELECT
songs.*
FROM songs
JOIN albums ON albums.source_id = songs.source_id AND albums.id = songs.album_id
WHERE $predicate
ORDER BY $order
LIMIT $limit;
songsByGenreCount:
SELECT
COUNT(*)
FROM songs
WHERE songs.source_id = :source_id AND songs.genre = :genre;
songsWithDownloadTasks:
SELECT * FROM songs
WHERE download_task_id IS NOT NULL;
songByDownloadTask:
SELECT * FROM songs
WHERE download_task_id = :task_id;
clearSongDownloadTaskBySong:
UPDATE songs SET
download_task_id = NULL
WHERE source_id = :source_id AND id = :id;
completeSongDownload:
UPDATE songs SET
download_task_id = NULL,
download_file_path = :file_path
WHERE download_task_id = :task_id;
clearSongDownloadTask:
UPDATE songs SET
download_task_id = NULL,
download_file_path = NULL
WHERE download_task_id = :task_id;
updateSongDownloadTask:
UPDATE songs SET
download_task_id = :task_id
WHERE source_id = :source_id AND id = :id;
deleteSongDownloadFile:
UPDATE songs SET
download_task_id = NULL,
download_file_path = NULL
WHERE source_id = :source_id AND id = :id;
albumDownloadStatus WITH ListDownloadStatus:
SELECT
COUNT(*) as total,
COUNT(CASE WHEN songs.download_file_path IS NOT NULL THEN songs.id ELSE NULL END) AS downloaded,
COUNT(CASE WHEN songs.download_task_id IS NOT NULL THEN songs.id ELSE NULL END) AS downloading
FROM albums
JOIN songs ON albums.source_id = songs.source_id AND albums.id = songs.album_id
WHERE albums.source_id = :source_id AND albums.id = :id;
playlistDownloadStatus WITH ListDownloadStatus:
SELECT
COUNT(DISTINCT songs.id) as total,
COUNT(DISTINCT CASE WHEN songs.download_file_path IS NOT NULL THEN songs.id ELSE NULL END) AS downloaded,
COUNT(DISTINCT CASE WHEN songs.download_task_id IS NOT NULL THEN songs.id ELSE NULL END) AS downloading
FROM playlists
JOIN playlist_songs ON
playlist_songs.source_id = playlists.source_id
AND playlist_songs.playlist_id = playlists.id
JOIN songs ON
songs.source_id = playlist_songs.source_id
AND songs.id = playlist_songs.song_id
WHERE
playlists.source_id = :source_id AND playlists.id = :id;
filterAlbums:
SELECT
albums.*
FROM albums
WHERE $predicate
ORDER BY $order
LIMIT $limit;
filterAlbumsDownloaded:
SELECT
albums.*
FROM albums
LEFT JOIN songs ON albums.source_id = songs.source_id AND albums.id = songs.album_id
WHERE $predicate
GROUP BY albums.source_id, albums.id
HAVING SUM(CASE WHEN songs.download_file_path IS NOT NULL THEN 1 ELSE 0 END) > 0
ORDER BY $order
LIMIT $limit;
filterArtists:
SELECT
artists.*
FROM artists
WHERE $predicate
ORDER BY $order
LIMIT $limit;
filterArtistsDownloaded WITH Artist:
SELECT
artists.*,
COUNT(DISTINCT CASE WHEN songs.download_file_path IS NOT NULL THEN songs.album_id ELSE NULL END) AS album_count
FROM artists
LEFT JOIN albums ON artists.source_id = albums.source_id AND artists.id = albums.artist_id
LEFT JOIN songs ON albums.source_id = songs.source_id AND albums.id = songs.album_id
WHERE $predicate
GROUP BY artists.source_id, artists.id
HAVING SUM(CASE WHEN songs.download_file_path IS NOT NULL THEN 1 ELSE 0 END) > 0
ORDER BY $order
LIMIT $limit;
filterPlaylists:
SELECT
playlists.*
FROM playlists
WHERE $predicate
ORDER BY $order
LIMIT $limit;
filterPlaylistsDownloaded WITH Playlist:
SELECT
playlists.*,
COUNT(CASE WHEN songs.download_file_path IS NOT NULL THEN songs.id ELSE NULL END) AS song_count
FROM playlists
LEFT JOIN playlist_songs ON playlist_songs.source_id = playlists.source_id AND playlist_songs.playlist_id = playlists.id
LEFT JOIN songs ON playlist_songs.source_id = songs.source_id AND playlist_songs.song_id = songs.id
WHERE $predicate
GROUP BY playlists.source_id, playlists.id
HAVING SUM(CASE WHEN songs.download_file_path IS NOT NULL THEN 1 ELSE 0 END) > 0
ORDER BY $order
LIMIT $limit;
filterSongs:
SELECT
songs.*
FROM songs
WHERE $predicate
ORDER BY $order
LIMIT $limit;
filterSongsDownloaded:
SELECT
songs.*
FROM songs
WHERE $predicate AND songs.download_file_path IS NOT NULL
ORDER BY $order
LIMIT $limit;
playlistIsDownloaded:
SELECT
COUNT(*) = 0
FROM playlists
JOIN playlist_songs ON
playlist_songs.source_id = playlists.source_id
AND playlist_songs.playlist_id = playlists.id
JOIN songs ON
songs.source_id = playlist_songs.source_id
AND songs.id = playlist_songs.song_id
WHERE
playlists.source_id = :source_id AND playlists.id = :id
AND songs.download_file_path IS NULL;
playlistHasDownloadsInProgress:
SELECT
COUNT(*) > 0
FROM playlists
JOIN playlist_songs ON
playlist_songs.source_id = playlists.source_id
AND playlist_songs.playlist_id = playlists.id
JOIN songs ON
songs.source_id = playlist_songs.source_id
AND songs.id = playlist_songs.song_id
WHERE playlists.source_id = :source_id AND playlists.id = :id
AND songs.download_task_id IS NOT NULL;
songsInIds:
SELECT *
FROM songs
WHERE source_id = :source_id AND id IN :ids;
songsInRowIds:
SELECT *
FROM songs
WHERE ROWID IN :row_ids;
albumsInRowIds:
SELECT *
FROM albums
WHERE ROWID IN :row_ids;
artistsInRowIds:
SELECT *
FROM artists
WHERE ROWID IN :row_ids;
playlistsInRowIds:
SELECT *
FROM playlists
WHERE ROWID IN :row_ids;
currentTrackIndex:
SELECT
queue."index"
FROM queue
WHERE queue.current_track = 1;
queueLength:
SELECT COUNT(*) FROM queue;
queueInIndicies:
SELECT *
FROM queue
WHERE queue."index" IN :indicies;
getAppSettings:
SELECT * FROM app_settings
WHERE id = 1;

Some files were not shown because too many files have changed in this diff Show More